diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 2f3f16a024..29503d7b6b 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -39,7 +39,7 @@ jobs: uses: ./.github/actions/setup-web - name: Run tests - run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage + run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage - name: Upload blob report if: ${{ !cancelled() }} diff --git a/api/.env.example b/api/.env.example index 34be400e87..833d83797d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -767,6 +767,7 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub # Whether to use Redis cluster mode while use redis as event bus. # It's highly recommended to enable this for large deployments. EVENT_BUS_REDIS_USE_CLUSTERS=false +EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000 # Whether to Enable human input timeout check task ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 41b9ce059d..a886fe849f 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -49,6 +49,7 @@ class AgentBackendModelConfig(BaseModel): model: str user_id: str | None = None credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) + model_settings: dict[str, JsonValue] = Field(default_factory=dict) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -138,6 +139,7 @@ class AgentBackendRunRequestBuilder: model_provider=run_input.model.model_provider, model=run_input.model.model, credentials=run_input.model.credentials, + model_settings=run_input.model.model_settings or None, ), ), ] diff --git a/api/configs/extra/__init__.py b/api/configs/extra/__init__.py index de97adfc0e..a2246db208 100644 --- a/api/configs/extra/__init__.py +++ b/api/configs/extra/__init__.py @@ -1,3 +1,4 @@ +from configs.extra.agent_backend_config import AgentBackendConfig from configs.extra.archive_config import ArchiveStorageConfig from configs.extra.notion_config import NotionConfig from configs.extra.sentry_config import SentryConfig @@ -5,6 +6,7 @@ from configs.extra.sentry_config import SentryConfig class ExtraServiceConfig( # place the configs in alphabet order + AgentBackendConfig, ArchiveStorageConfig, NotionConfig, SentryConfig, diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py new file mode 100644 index 0000000000..ae1dc2ed22 --- /dev/null +++ b/api/configs/extra/agent_backend_config.py @@ -0,0 +1,23 @@ +from pydantic import Field +from pydantic_settings import BaseSettings + + +class AgentBackendConfig(BaseSettings): + """ + Configuration settings for the Agent backend runtime integration. + """ + + AGENT_BACKEND_BASE_URL: str | None = Field( + description="Base URL for the Dify Agent backend service.", + default=None, + ) + + AGENT_BACKEND_USE_FAKE: bool = Field( + description="Use the deterministic in-process fake Agent backend client.", + default=False, + ) + + AGENT_BACKEND_FAKE_SCENARIO: str = Field( + description="Scenario used by the fake Agent backend client.", + default="success", + ) diff --git a/api/configs/middleware/cache/redis_pubsub_config.py b/api/configs/middleware/cache/redis_pubsub_config.py index 0a166818b3..d465f2e93c 100644 --- a/api/configs/middleware/cache/redis_pubsub_config.py +++ b/api/configs/middleware/cache/redis_pubsub_config.py @@ -2,6 +2,7 @@ from typing import Literal, Protocol, cast from urllib.parse import quote_plus, urlunparse from pydantic import AliasChoices, Field +from pydantic.types import NonNegativeInt from pydantic_settings import BaseSettings @@ -70,6 +71,24 @@ class RedisPubSubConfig(BaseSettings): default=600, ) + PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field( + validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"), + description=( + "Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to " + "finish before returning. Bounds the tail latency between a terminal event being delivered to " + "an SSE client and the response stream actually closing.\n\n" + "The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout " + "for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for " + "the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() " + "return promptly while the daemon listener thread cleans itself up on the next poll " + "boundary - safe because the listener holds no critical state and exits within one poll " + "window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up " + "and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n" + "Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS." + ), + default=2000, + ) + def _build_default_pubsub_url(self) -> str: defaults = _redis_defaults(self) if not defaults.REDIS_HOST or not defaults.REDIS_PORT: diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 8c8acc8d0a..3e4ea0a0ba 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -177,14 +177,9 @@ class DatasetListApi(DatasetApiResource): data = marshal(datasets, dataset_detail_fields) for item in data: - if ( - item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY # pyrefly: ignore[bad-index] - and item["embedding_model_provider"] # pyrefly: ignore[bad-index] - ): - item["embedding_model_provider"] = str( # pyrefly: ignore[unsupported-operation] - ModelProviderID(item["embedding_model_provider"]) # pyrefly: ignore[bad-index] - ) - item_model = f"{item['embedding_model']}:{item['embedding_model_provider']}" # pyrefly: ignore[bad-index] + if item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY and item["embedding_model_provider"]: + item["embedding_model_provider"] = str(ModelProviderID(item["embedding_model_provider"])) + item_model = f"{item['embedding_model']}:{item['embedding_model_provider']}" if item_model in model_names: item["embedding_available"] = True # type: ignore else: diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index cc7bc64439..571d5b66c0 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -55,6 +55,7 @@ from libs.flask_utils import preserve_flask_contexts from models import Account, App, Conversation, EndUser, Message, Workflow, WorkflowNodeExecutionTriggeredFrom from models.enums import WorkflowRunTriggeredFrom from services.conversation_service import ConversationService +from services.errors.conversation import ConversationNotExistsError from services.workflow_draft_variable_service import ( DraftVarLoader, WorkflowDraftVariableService, @@ -145,9 +146,15 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): conversation = None conversation_id = args.get("conversation_id") if conversation_id: - conversation = ConversationService.get_conversation( - app_model=app_model, conversation_id=conversation_id, user=user - ) + try: + conversation = ConversationService.get_conversation( + app_model=app_model, conversation_id=conversation_id, user=user + ) + except ConversationNotExistsError: + if invoke_from == InvokeFrom.SERVICE_API: + conversation = None + else: + raise # parse files # TODO(QuantumGhost): Move file parsing logic to the API controller layer diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 5d572bbd5e..baaa536a5c 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -37,6 +37,10 @@ from core.workflow.nodes.agent.plugin_strategy_adapter import ( PluginAgentStrategyResolver, ) from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport +from core.workflow.nodes.agent_v2 import DifyAgentNode +from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver +from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter +from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer from graphon.entities.base_node_data import BaseNodeData @@ -438,12 +442,7 @@ class DifyNodeFactory(NodeFactory): "tool_file_manager": self._bound_tool_file_manager_factory(), "runtime": self._tool_runtime, }, - BuiltinNodeTypes.AGENT: lambda: { - "strategy_resolver": self._agent_strategy_resolver, - "presentation_provider": self._agent_strategy_presentation_provider, - "runtime_support": self._agent_runtime_support, - "message_transformer": self._agent_message_transformer, - }, + BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class), } node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True) @@ -469,6 +468,32 @@ class DifyNodeFactory(NodeFactory): def _resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]: return resolve_workflow_node_class(node_type=node_type, node_version=node_version) + def _build_agent_node_init_kwargs(self, *, node_class: type[Node]) -> dict[str, object]: + if issubclass(node_class, DifyAgentNode): + from clients.agent_backend import AgentBackendRunEventAdapter, AgentBackendRunRequestBuilder + from clients.agent_backend.factory import create_agent_backend_run_client + + return { + "binding_resolver": WorkflowAgentBindingResolver(), + "runtime_request_builder": WorkflowAgentRuntimeRequestBuilder( + credentials_provider=self._llm_credentials_provider, + request_builder=AgentBackendRunRequestBuilder(), + ), + "agent_backend_client": create_agent_backend_run_client( + base_url=dify_config.AGENT_BACKEND_BASE_URL, + use_fake=dify_config.AGENT_BACKEND_USE_FAKE, + fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, + ), + "event_adapter": AgentBackendRunEventAdapter(), + "output_adapter": WorkflowAgentOutputAdapter(), + } + return { + "strategy_resolver": self._agent_strategy_resolver, + "presentation_provider": self._agent_strategy_presentation_provider, + "runtime_support": self._agent_runtime_support, + "message_transformer": self._agent_message_transformer, + } + def _build_llm_compatible_node_init_kwargs( self, *, diff --git a/api/core/workflow/nodes/agent_v2/__init__.py b/api/core/workflow/nodes/agent_v2/__init__.py new file mode 100644 index 0000000000..eee4434472 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/__init__.py @@ -0,0 +1,4 @@ +from .agent_node import DifyAgentNode +from .entities import DifyAgentNodeData + +__all__ = ["DifyAgentNode", "DifyAgentNodeData"] diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py new file mode 100644 index 0000000000..0409579a74 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from collections.abc import Generator, Mapping, Sequence +from typing import TYPE_CHECKING, Any + +from clients.agent_backend import ( + AgentBackendError, + AgentBackendHTTPError, + AgentBackendInternalEventType, + AgentBackendRunClient, + AgentBackendRunEventAdapter, + AgentBackendRunFailedInternalEvent, + AgentBackendRunSucceededInternalEvent, + AgentBackendStreamError, + AgentBackendStreamInternalEvent, + AgentBackendTransportError, + AgentBackendValidationError, +) +from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext +from core.workflow.system_variables import SystemVariableKey, get_system_text +from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent +from graphon.nodes.base.node import Node + +from .binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver +from .entities import DifyAgentNodeData +from .output_adapter import WorkflowAgentOutputAdapter +from .runtime_request_builder import ( + WorkflowAgentRuntimeBuildContext, + WorkflowAgentRuntimeRequestBuilder, + WorkflowAgentRuntimeRequestBuildError, +) + +if TYPE_CHECKING: + from graphon.entities import GraphInitParams + from graphon.runtime import GraphRuntimeState + + +class DifyAgentNode(Node[DifyAgentNodeData]): + node_type = BuiltinNodeTypes.AGENT + + def __init__( + self, + node_id: str, + data: DifyAgentNodeData, + *, + graph_init_params: GraphInitParams, + graph_runtime_state: GraphRuntimeState, + binding_resolver: WorkflowAgentBindingResolver, + runtime_request_builder: WorkflowAgentRuntimeRequestBuilder, + agent_backend_client: AgentBackendRunClient, + event_adapter: AgentBackendRunEventAdapter, + output_adapter: WorkflowAgentOutputAdapter, + ) -> None: + super().__init__( + node_id=node_id, + data=data, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + ) + self._binding_resolver = binding_resolver + self._runtime_request_builder = runtime_request_builder + self._agent_backend_client = agent_backend_client + self._event_adapter = event_adapter + self._output_adapter = output_adapter + + @classmethod + def version(cls) -> str: + return "2" + + def populate_start_event(self, event) -> None: + event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind} + + def _run(self) -> Generator[NodeEventBase, None, None]: + dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) + workflow_id = self.graph_init_params.workflow_id + workflow_run_id = get_system_text( + self.graph_runtime_state.variable_pool, + SystemVariableKey.WORKFLOW_EXECUTION_ID, + ) + inputs: dict[str, Any] = {} + process_data: dict[str, Any] = {} + metadata: dict[str, Any] = { + "agent_backend": { + "status": "not_started", + } + } + + try: + bundle = self._binding_resolver.resolve( + tenant_id=dify_ctx.tenant_id, + app_id=dify_ctx.app_id, + workflow_id=workflow_id, + node_id=self._node_id, + ) + runtime_request = self._runtime_request_builder.build( + WorkflowAgentRuntimeBuildContext( + dify_context=dify_ctx, + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + node_id=self._node_id, + node_execution_id=self.id, + variable_pool=self.graph_runtime_state.variable_pool, + binding=bundle.binding, + agent=bundle.agent, + snapshot=bundle.snapshot, + ) + ) + inputs = {"agent_backend_request": runtime_request.redacted_request} + metadata = dict(runtime_request.metadata) + process_data = { + "agent_id": bundle.agent.id, + "agent_config_snapshot_id": bundle.snapshot.id, + "binding_id": bundle.binding.id, + } + create_response = self._agent_backend_client.create_run(runtime_request.request) + metadata["agent_backend"] = { + **dict(metadata.get("agent_backend") or {}), + "run_id": create_response.run_id, + "status": create_response.status, + } + except WorkflowAgentBindingError as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type=error.error_code, + ) + return + except WorkflowAgentRuntimeRequestBuildError as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type=error.error_code, + ) + return + except AgentBackendError as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type=self._agent_backend_error_type(error), + ) + return + except Exception as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type="agent_workflow_node_runtime_error", + ) + return + + stream_event_count = 0 + try: + for public_event in self._agent_backend_client.stream_events(create_response.run_id): + stream_event_count += 1 + for internal_event in self._event_adapter.adapt(public_event): + if internal_event.type == AgentBackendInternalEventType.RUN_STARTED: + continue + if internal_event.type == AgentBackendInternalEventType.STREAM_EVENT: + if isinstance(internal_event, AgentBackendStreamInternalEvent): + self._record_stream_metadata(metadata, internal_event) + continue + metadata["agent_backend"] = { + **dict(metadata.get("agent_backend") or {}), + "stream_event_count": stream_event_count, + } + if isinstance(internal_event, AgentBackendRunSucceededInternalEvent): + yield StreamCompletedEvent( + node_run_result=self._output_adapter.build_success_result( + event=internal_event, + inputs=inputs, + process_data=process_data, + metadata=metadata, + ) + ) + return + if isinstance( + internal_event, + AgentBackendRunFailedInternalEvent, + ) or internal_event.type in { + AgentBackendInternalEventType.RUN_CANCELLED, + AgentBackendInternalEventType.RUN_PAUSED, + }: + yield StreamCompletedEvent( + node_run_result=self._output_adapter.build_failure_result( + event=internal_event, + inputs=inputs, + process_data=process_data, + metadata=metadata, + ) + ) + return + except AgentBackendError as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type=self._agent_backend_error_type(error), + ) + return + except Exception as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type="agent_backend_stream_error", + ) + return + + yield StreamCompletedEvent( + node_run_result=self._output_adapter.build_stream_exhausted_result( + inputs=inputs, + process_data=process_data, + metadata=metadata, + ) + ) + + @staticmethod + def _failure_event( + *, + inputs: dict[str, Any], + process_data: dict[str, Any], + metadata: dict[str, Any], + error: str, + error_type: str, + ) -> StreamCompletedEvent: + return StreamCompletedEvent( + node_run_result=NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + inputs=inputs, + process_data=process_data, + metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata}, + outputs={}, + error=error, + error_type=error_type, + ) + ) + + @staticmethod + def _agent_backend_error_type(error: AgentBackendError) -> str: + if isinstance(error, AgentBackendValidationError): + return "agent_backend_validation_error" + if isinstance(error, AgentBackendHTTPError): + return "agent_backend_http_error" + if isinstance(error, AgentBackendStreamError): + return "agent_backend_stream_error" + if isinstance(error, AgentBackendTransportError): + return "agent_backend_transport_error" + return "agent_backend_error" + + @staticmethod + def _record_stream_metadata(metadata: dict[str, Any], event: AgentBackendStreamInternalEvent) -> None: + agent_backend = dict(metadata.get("agent_backend") or {}) + agent_backend["last_stream_event_id"] = event.source_event_id + if event.event_kind: + agent_backend["last_stream_event_kind"] = event.event_kind + if isinstance(event.data, Mapping): + usage = event.data.get("usage") or event.data.get("model_usage") + if isinstance(usage, Mapping): + agent_backend["usage"] = dict(usage) + metadata["agent_backend"] = agent_backend + + @classmethod + def _extract_variable_selector_to_variable_mapping( + cls, + *, + graph_config: Mapping[str, Any], + node_id: str, + node_data: DifyAgentNodeData, + ) -> Mapping[str, Sequence[str]]: + del graph_config, node_id, node_data + return {} diff --git a/api/core/workflow/nodes/agent_v2/binding_resolver.py b/api/core/workflow/nodes/agent_v2/binding_resolver.py new file mode 100644 index 0000000000..d2f50b0ae4 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/binding_resolver.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select + +from core.db.session_factory import session_factory +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding + + +class WorkflowAgentBindingError(Exception): + error_code: str + + def __init__(self, error_code: str, message: str) -> None: + self.error_code = error_code + super().__init__(message) + + +@dataclass(frozen=True, slots=True) +class WorkflowAgentBindingBundle: + binding: WorkflowAgentNodeBinding + agent: Agent + snapshot: AgentConfigSnapshot + + +class WorkflowAgentBindingResolver: + """Resolve the Agent binding owned by the current workflow id and node id.""" + + def resolve( + self, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + ) -> WorkflowAgentBindingBundle: + with session_factory.create_session() as session: + binding = session.scalar( + select(WorkflowAgentNodeBinding) + .where( + WorkflowAgentNodeBinding.tenant_id == tenant_id, + WorkflowAgentNodeBinding.app_id == app_id, + WorkflowAgentNodeBinding.workflow_id == workflow_id, + WorkflowAgentNodeBinding.node_id == node_id, + ) + .limit(1) + ) + if binding is None: + raise WorkflowAgentBindingError( + "agent_binding_not_found", + f"Workflow Agent binding not found for node {node_id}.", + ) + if binding.agent_id is None: + raise WorkflowAgentBindingError("agent_not_available", "Workflow Agent binding has no agent.") + if binding.current_snapshot_id is None: + raise WorkflowAgentBindingError( + "agent_config_snapshot_not_found", + "Workflow Agent binding has no current config snapshot.", + ) + + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.id == binding.agent_id, + ) + .limit(1) + ) + if agent is None or agent.status == AgentStatus.ARCHIVED: + raise WorkflowAgentBindingError( + "agent_not_available", + f"Agent {binding.agent_id} is not available.", + ) + + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == binding.current_snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + raise WorkflowAgentBindingError( + "agent_config_snapshot_not_found", + f"Agent config snapshot {binding.current_snapshot_id} not found.", + ) + + session.expunge(binding) + session.expunge(agent) + session.expunge(snapshot) + return WorkflowAgentBindingBundle(binding=binding, agent=agent, snapshot=snapshot) diff --git a/api/core/workflow/nodes/agent_v2/entities.py b/api/core/workflow/nodes/agent_v2/entities.py new file mode 100644 index 0000000000..eb36b9cf1e --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/entities.py @@ -0,0 +1,17 @@ +from typing import Literal + +from pydantic import model_validator + +from graphon.entities.base_node_data import BaseNodeData +from graphon.enums import BuiltinNodeTypes, NodeType + + +class DifyAgentNodeData(BaseNodeData): + type: NodeType = BuiltinNodeTypes.AGENT + agent_node_kind: Literal["dify_agent"] = "dify_agent" + + @model_validator(mode="after") + def validate_version(self) -> "DifyAgentNodeData": + if self.version != "2": + raise ValueError("Dify Agent Node v2 requires version='2'") + return self diff --git a/api/core/workflow/nodes/agent_v2/output_adapter.py b/api/core/workflow/nodes/agent_v2/output_adapter.py new file mode 100644 index 0000000000..0aecfec4a6 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/output_adapter.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from clients.agent_backend import ( + AgentBackendInternalEvent, + AgentBackendInternalEventType, + AgentBackendRunCancelledInternalEvent, + AgentBackendRunFailedInternalEvent, + AgentBackendRunPausedInternalEvent, + AgentBackendRunSucceededInternalEvent, +) +from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from graphon.file import File, FileTransferMethod, FileType +from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.node_events import NodeRunResult +from graphon.variables.segments import ArrayFileSegment, FileSegment + + +class WorkflowAgentOutputAdapter: + """Convert terminal Agent backend events into workflow node run results.""" + + def build_success_result( + self, + *, + event: AgentBackendRunSucceededInternalEvent, + inputs: dict[str, Any], + process_data: dict[str, Any], + metadata: dict[str, Any], + ) -> NodeRunResult: + metadata = self._with_terminal_metadata(metadata, event, "succeeded") + usage = self._usage_from_metadata(metadata) + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=inputs, + process_data=process_data, + outputs=self._normalize_outputs(event.output), + metadata=self._build_node_metadata(metadata=metadata, usage=usage), + llm_usage=usage or LLMUsage.empty_usage(), + ) + + def build_failure_result( + self, + *, + event: ( + AgentBackendRunFailedInternalEvent + | AgentBackendRunCancelledInternalEvent + | AgentBackendRunPausedInternalEvent + ), + inputs: dict[str, Any], + process_data: dict[str, Any], + metadata: dict[str, Any], + ) -> NodeRunResult: + status = WorkflowNodeExecutionStatus.FAILED + error = "Agent backend run failed." + error_type = "agent_backend_run_failed" + terminal_status = "failed" + + match event: + case AgentBackendRunFailedInternalEvent(): + error = event.error + error_type = event.reason or "agent_backend_run_failed" + terminal_status = "failed" + case AgentBackendRunCancelledInternalEvent(): + error = event.message or "Agent backend run was cancelled." + error_type = "agent_backend_run_cancelled" + terminal_status = "cancelled" + case AgentBackendRunPausedInternalEvent(): + error = event.message or "Agent backend run paused, but workflow Agent Node pause is not supported yet." + error_type = "agent_backend_paused_unsupported" + terminal_status = "paused" + + metadata = self._with_terminal_metadata(metadata, event, terminal_status) + usage = self._usage_from_metadata(metadata) + return NodeRunResult( + status=status, + inputs=inputs, + process_data=process_data, + metadata=self._build_node_metadata(metadata=metadata, usage=usage), + llm_usage=usage or LLMUsage.empty_usage(), + error=error, + error_type=error_type, + ) + + def build_stream_exhausted_result( + self, + *, + inputs: dict[str, Any], + process_data: dict[str, Any], + metadata: dict[str, Any], + ) -> NodeRunResult: + usage = self._usage_from_metadata(metadata) + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + inputs=inputs, + process_data=process_data, + metadata=self._build_node_metadata(metadata=metadata, usage=usage), + llm_usage=usage or LLMUsage.empty_usage(), + error="Agent backend stream ended before a terminal event.", + error_type="agent_backend_stream_error", + ) + + @classmethod + def _normalize_outputs(cls, output: Any) -> dict[str, Any]: + if isinstance(output, dict): + if cls._is_file_payload(output): + return {"file": cls._file_segment_from_payload(output)} + return {key: cls._normalize_output_value(value) for key, value in output.items()} + if isinstance(output, str): + return {"text": output} + return {"result": output} + + @classmethod + def _normalize_output_value(cls, value: Any) -> Any: + if isinstance(value, File | FileSegment | ArrayFileSegment): + return value + if isinstance(value, Mapping): + if cls._is_file_payload(value): + return cls._file_segment_from_payload(value) + return {key: cls._normalize_output_value(item) for key, item in value.items()} + if isinstance(value, list): + if value and all(isinstance(item, Mapping) and cls._is_file_payload(item) for item in value): + return ArrayFileSegment(value=[cls._file_from_payload(item) for item in value]) + return [cls._normalize_output_value(item) for item in value] + return value + + @staticmethod + def _is_file_payload(value: Mapping[str, Any]) -> bool: + return any(value.get(key) for key in ("file_id", "upload_file_id", "tool_file_id", "url", "remote_url")) and ( + "filename" in value or "mime_type" in value or "url" in value or "remote_url" in value + ) + + @classmethod + def _file_segment_from_payload(cls, value: Mapping[str, Any]) -> FileSegment: + return FileSegment(value=cls._file_from_payload(value)) + + @classmethod + def _file_from_payload(cls, value: Mapping[str, Any]) -> File: + remote_url = cls._string_value(value.get("remote_url") or value.get("url")) + upload_file_id = cls._string_value(value.get("upload_file_id") or value.get("file_id")) + tool_file_id = cls._string_value(value.get("tool_file_id")) + filename = cls._string_value(value.get("filename") or value.get("name")) + mime_type = cls._string_value(value.get("mime_type") or value.get("mimetype")) + extension = cls._extension_from_payload(value, filename) + file_type = cls._file_type_from_payload(value, mime_type) + size = value.get("size") + if not isinstance(size, int): + size = -1 + + if tool_file_id: + transfer_method = FileTransferMethod.TOOL_FILE + related_id = tool_file_id + elif remote_url: + transfer_method = FileTransferMethod.REMOTE_URL + related_id = None + else: + transfer_method = FileTransferMethod.LOCAL_FILE + related_id = upload_file_id + + return File( + type=file_type, + transfer_method=transfer_method, + remote_url=remote_url if transfer_method == FileTransferMethod.REMOTE_URL else None, + related_id=related_id, + filename=filename, + extension=extension, + mime_type=mime_type, + size=size, + ) + + @staticmethod + def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + @classmethod + def _extension_from_payload(cls, value: Mapping[str, Any], filename: str | None) -> str | None: + extension = cls._string_value(value.get("extension")) + if extension: + return extension if extension.startswith(".") else f".{extension}" + if filename and "." in filename: + return f".{filename.rsplit('.', 1)[1]}" + return None + + @staticmethod + def _file_type_from_payload(value: Mapping[str, Any], mime_type: str | None) -> FileType: + explicit_type = value.get("type") or value.get("file_type") + if isinstance(explicit_type, str): + try: + return FileType(explicit_type) + except ValueError: + pass + if mime_type: + if mime_type.startswith("image/"): + return FileType.IMAGE + if mime_type.startswith("audio/"): + return FileType.AUDIO + if mime_type.startswith("video/"): + return FileType.VIDEO + return FileType.DOCUMENT + return FileType.CUSTOM + + @staticmethod + def _usage_from_metadata(metadata: Mapping[str, Any]) -> LLMUsage | None: + agent_backend = metadata.get("agent_backend") + if not isinstance(agent_backend, Mapping): + return None + usage = agent_backend.get("usage") + if not isinstance(usage, Mapping): + return None + try: + return LLMUsage.from_metadata(usage) + except (TypeError, ValueError): + return None + + @staticmethod + def _build_node_metadata( + *, + metadata: dict[str, Any], + usage: LLMUsage | None, + ) -> dict[WorkflowNodeExecutionMetadataKey, Any]: + node_metadata: dict[WorkflowNodeExecutionMetadataKey, Any] = { + WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata, + } + if usage is not None: + node_metadata[WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS] = usage.total_tokens + node_metadata[WorkflowNodeExecutionMetadataKey.TOTAL_PRICE] = usage.total_price + node_metadata[WorkflowNodeExecutionMetadataKey.CURRENCY] = usage.currency + return node_metadata + + @staticmethod + def _with_terminal_metadata( + metadata: dict[str, Any], + event: AgentBackendInternalEvent, + terminal_status: str, + ) -> dict[str, Any]: + updated = dict(metadata) + agent_backend = dict(updated.get("agent_backend") or {}) + agent_backend.update( + { + "run_id": event.run_id, + "terminal_event_id": event.source_event_id, + "status": terminal_status, + } + ) + session_snapshot = None + if isinstance(event, AgentBackendRunSucceededInternalEvent | AgentBackendRunPausedInternalEvent): + session_snapshot = event.session_snapshot + if session_snapshot is not None: + agent_backend["session_snapshot"] = { + "layer_count": len(session_snapshot.layers), + } + updated["agent_backend"] = agent_backend + updated["terminal_event_type"] = AgentBackendInternalEventType(event.type).value + return updated diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py new file mode 100644 index 0000000000..afd730f652 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Any + +from models.agent_config_entities import AgentSoulConfig + +SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( + { + "system_prompt", + "workflow_prompt", + "workflow_context", + "model", + "structured_output", + } +) + +RESERVED_AGENT_BACKEND_FEATURES = frozenset( + { + "skills_files", + "tools", + "knowledge", + "human", + "env", + "sandbox", + "memory", + } +) + + +def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]: + """Describe PRD capabilities that are persisted but not executed in phase 3.""" + warnings: list[dict[str, str]] = [] + soul_dump = agent_soul.model_dump(mode="json") + for section in sorted(RESERVED_AGENT_BACKEND_FEATURES): + value = soul_dump.get(section) + has_value = bool(value) + if isinstance(value, dict): + has_value = any(bool(item) for item in value.values()) + if has_value: + warnings.append( + { + "section": f"agent_soul.{section}", + "code": "agent_backend_layer_not_available", + "message": f"{section} is saved in Agent Soul but is not executed by Agent backend in phase 3.", + } + ) + + reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") + + return { + "supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES), + "reserved": sorted(RESERVED_AGENT_BACKEND_FEATURES), + "reserved_status": reserved_status, + "unsupported_runtime_warnings": warnings, + } diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py new file mode 100644 index 0000000000..66a4418b68 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal, Protocol, cast + +from dify_agent.protocol import CreateRunRequest, ExecutionContext + +from clients.agent_backend import ( + AgentBackendModelConfig, + AgentBackendOutputConfig, + AgentBackendRunRequestBuilder, + AgentBackendWorkflowNodeRunInput, + redact_for_agent_backend_log, +) +from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom +from core.workflow.system_variables import SystemVariableKey, get_system_text +from graphon.variables.segments import Segment +from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding +from models.agent_config_entities import ( + AgentSoulConfig, + DeclaredOutputConfig, + DeclaredOutputType, + WorkflowNodeJobConfig, +) + +from .runtime_feature_manifest import build_runtime_feature_manifest + + +class WorkflowAgentRuntimeRequestBuildError(ValueError): + """Raised when workflow state cannot be mapped to a valid Agent backend run request.""" + + def __init__(self, error_code: str, message: str) -> None: + self.error_code = error_code + super().__init__(message) + + +class VariablePoolReader(Protocol): + def get(self, selector: Sequence[str], /) -> Segment | None: ... + + def get_by_prefix(self, prefix: str, /) -> Mapping[str, object]: ... + + +class CredentialsProvider(Protocol): + def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: ... + + +@dataclass(frozen=True, slots=True) +class WorkflowAgentRuntimeBuildContext: + dify_context: DifyRunContext + workflow_id: str + workflow_run_id: str | None + node_id: str + node_execution_id: str + variable_pool: VariablePoolReader + binding: WorkflowAgentNodeBinding + agent: Agent + snapshot: AgentConfigSnapshot + + +@dataclass(frozen=True, slots=True) +class WorkflowAgentRuntimeRequest: + request: CreateRunRequest + redacted_request: dict[str, Any] + agent_soul: AgentSoulConfig + node_job: WorkflowNodeJobConfig + metadata: dict[str, Any] + + +class WorkflowAgentRuntimeRequestBuilder: + """Build public Dify Agent run requests from workflow Agent v2 runtime state.""" + + def __init__( + self, + *, + credentials_provider: CredentialsProvider, + request_builder: AgentBackendRunRequestBuilder | None = None, + ) -> None: + self._credentials_provider = credentials_provider + self._request_builder = request_builder or AgentBackendRunRequestBuilder() + + def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest: + agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict) + node_job = WorkflowNodeJobConfig.model_validate(context.binding.node_job_config_dict) + if agent_soul.model is None: + raise WorkflowAgentRuntimeRequestBuildError( + "agent_model_not_configured", + "Workflow Agent node requires Agent Soul model config.", + ) + + metadata = self._build_metadata(context, agent_soul, node_job) + workflow_context_prompt = self._build_workflow_context_prompt(context, node_job) + workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run." + user_prompt = workflow_context_prompt.strip() or "Use the current workflow context." + credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model) + + request = self._request_builder.build_for_workflow_node( + AgentBackendWorkflowNodeRunInput( + model=AgentBackendModelConfig( + tenant_id=context.dify_context.tenant_id, + plugin_id=agent_soul.model.plugin_id, + model_provider=agent_soul.model.model_provider, + model=agent_soul.model.model, + user_id=context.dify_context.user_id, + credentials=self._normalize_credentials(credentials), + model_settings=cast(dict[str, Any], agent_soul.model.model_settings), + ), + execution_context=ExecutionContext( + tenant_id=context.dify_context.tenant_id, + app_id=context.dify_context.app_id, + workflow_id=context.workflow_id, + workflow_run_id=context.workflow_run_id, + node_id=context.node_id, + node_execution_id=context.node_execution_id, + conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID), + agent_id=context.agent.id, + agent_config_version_id=context.snapshot.id, + invoke_from=self._agent_backend_invoke_from(context.dify_context.invoke_from), + ), + agent_soul_prompt=agent_soul.prompt.system_prompt or None, + workflow_node_job_prompt=workflow_job_prompt, + user_prompt=user_prompt, + output=self._build_output_config(node_job.declared_outputs), + idempotency_key=self._idempotency_key(context), + metadata=metadata, + ) + ) + redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) + return WorkflowAgentRuntimeRequest( + request=request, + redacted_request=redacted, + agent_soul=agent_soul, + node_job=node_job, + metadata=metadata, + ) + + @staticmethod + def _agent_backend_invoke_from(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]: + if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}: + return "single_step" + return "workflow_run" + + @staticmethod + def _idempotency_key(context: WorkflowAgentRuntimeBuildContext) -> str: + if context.workflow_run_id: + return f"{context.workflow_run_id}:{context.node_execution_id}" + return context.node_execution_id + + @staticmethod + def _build_metadata( + context: WorkflowAgentRuntimeBuildContext, + agent_soul: AgentSoulConfig, + node_job: WorkflowNodeJobConfig, + ) -> dict[str, Any]: + return { + "tenant_id": context.dify_context.tenant_id, + "app_id": context.dify_context.app_id, + "workflow_id": context.workflow_id, + "workflow_run_id": context.workflow_run_id, + "node_id": context.node_id, + "node_execution_id": context.node_execution_id, + "agent_id": context.agent.id, + "agent_config_snapshot_id": context.snapshot.id, + "binding_id": context.binding.id, + "workflow_node_job_mode": node_job.mode.value, + "runtime_support": build_runtime_feature_manifest(agent_soul), + } + + def _build_workflow_context_prompt( + self, + context: WorkflowAgentRuntimeBuildContext, + node_job: WorkflowNodeJobConfig, + ) -> str: + lines = ["Workflow context loaded for this run:"] + query = get_system_text(context.variable_pool, SystemVariableKey.QUERY) + if query: + lines.append(f"- User query: {query}") + + resolved_outputs = self._resolve_previous_node_outputs( + context.variable_pool, + node_job.previous_node_output_refs, + ) + if resolved_outputs: + lines.append("- Previous node outputs:") + for item in resolved_outputs: + lines.append(f" - {item['label']}: {item['value']}") + + lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.") + return "\n".join(lines) + + def _resolve_previous_node_outputs( + self, + variable_pool: VariablePoolReader, + refs: Sequence[Mapping[str, Any]], + ) -> list[dict[str, Any]]: + resolved: list[dict[str, Any]] = [] + for ref in refs: + selector = self._selector_from_ref(ref) + if not selector: + raise WorkflowAgentRuntimeRequestBuildError( + "invalid_previous_node_output_ref", + "Workflow Agent node has invalid previous node output ref.", + ) + segment = variable_pool.get(selector) + if segment is None: + raise WorkflowAgentRuntimeRequestBuildError( + "missing_previous_node_output", + f"Workflow Agent node cannot resolve previous node output {'.'.join(selector)}.", + ) + value = getattr(segment, "value", None) + resolved.append( + { + "label": ".".join(selector), + "value": self._summarize_value(value), + } + ) + return resolved + + @staticmethod + def _selector_from_ref(ref: Mapping[str, Any]) -> list[str] | None: + for key in ("selector", "variable_selector", "value_selector"): + value = ref.get(key) + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return value + node_id = ref.get("node_id") + output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key") + if isinstance(node_id, str) and isinstance(output_name, str): + return [node_id, output_name] + return None + + @staticmethod + def _summarize_value(value: Any) -> str: + text = str(value) + if len(text) > 2000: + return text[:2000] + "...[truncated]" + return text + + @staticmethod + def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None: + if not declared_outputs: + return None + properties: dict[str, Any] = {} + required: list[str] = [] + for output in declared_outputs: + properties[output.name] = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(output) + if output.required: + required.append(output.name) + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return AgentBackendOutputConfig(json_schema=schema) + + @staticmethod + def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]: + match output.type: + case DeclaredOutputType.STRING: + schema: dict[str, Any] = {"type": "string"} + case DeclaredOutputType.NUMBER: + schema = {"type": "number"} + case DeclaredOutputType.BOOLEAN: + schema = {"type": "boolean"} + case DeclaredOutputType.OBJECT: + schema = {"type": "object"} + case DeclaredOutputType.ARRAY: + schema = {"type": "array"} + case DeclaredOutputType.FILE: + schema = { + "type": "object", + "properties": { + "file_id": {"type": "string"}, + "filename": {"type": "string"}, + "mime_type": {"type": "string"}, + "url": {"type": "string"}, + }, + } + if output.description: + schema["description"] = output.description + return schema + + @staticmethod + def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]: + normalized: dict[str, str | int | float | bool | None] = {} + for key, value in credentials.items(): + if isinstance(value, str | int | float | bool) or value is None: + normalized[key] = value + else: + normalized[key] = str(value) + return normalized diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py new file mode 100644 index 0000000000..f54be8621a --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from graphon.enums import BuiltinNodeTypes +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from models.model import UploadFile +from models.workflow import Workflow + +from .entities import DifyAgentNodeData + + +class WorkflowAgentNodeValidationError(ValueError): + """Raised when a Workflow Agent v2 node cannot be executed or published.""" + + +class WorkflowAgentNodeValidator: + """Validate Agent v2 workflow nodes against graph topology and persisted bindings.""" + + _LOCKED_AGENT_SOUL_KEYS = frozenset( + { + "agent_soul", + "soul", + "prompt", + "system_prompt", + "skills_files", + "skills", + "files", + "tools", + "dify_tools", + "cli_tools", + "knowledge", + "env", + "environment", + "sandbox", + "sandbox_provider", + "memory", + "memory_strategy", + "model", + "app_features", + "app_variables", + "misc_legacy", + } + ) + _SUPPORTED_HUMAN_CONTACT_CHANNELS = frozenset({"email", "slack", "web_app", "webapp", "chat"}) + + @classmethod + def validate_draft_workflow(cls, *, session: Session, workflow: Workflow) -> None: + cls._validate_workflow(session=session, workflow=workflow, require_binding=False) + + @classmethod + def validate_published_workflow(cls, *, session: Session, workflow: Workflow) -> None: + cls._validate_workflow(session=session, workflow=workflow, require_binding=True) + + @classmethod + def _validate_workflow(cls, *, session: Session, workflow: Workflow, require_binding: bool) -> None: + graph = workflow.graph_dict + topology = _WorkflowGraphTopology.from_graph(graph) + for node_id, node_data in cls.iter_agent_v2_nodes(graph): + cls._validate_node_schema(node_id=node_id, node_data=node_data) + binding = cls._find_binding( + session=session, + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id=workflow.id, + node_id=node_id, + ) + if binding is None: + if require_binding: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {node_id} requires a binding before publishing." + ) + continue + cls.validate_binding(session=session, binding=binding, topology=topology) + + @classmethod + def validate_binding( + cls, + *, + session: Session, + binding: WorkflowAgentNodeBinding, + topology: _WorkflowGraphTopology | None = None, + ) -> None: + if binding.agent_id is None: + raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.") + if binding.current_snapshot_id is None: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} is missing config snapshot binding." + ) + + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == binding.tenant_id, + Agent.id == binding.agent_id, + ) + .limit(1) + ) + if agent is None or agent.status == AgentStatus.ARCHIVED: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references an unavailable agent." + ) + + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == binding.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == binding.current_snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references a missing config snapshot." + ) + + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + if agent_soul.model is None: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} requires Agent Soul model config." + ) + node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) + cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology) + + @classmethod + def validate_node_job( + cls, + *, + session: Session, + binding: WorkflowAgentNodeBinding, + node_job: WorkflowNodeJobConfig, + topology: _WorkflowGraphTopology | None = None, + ) -> None: + cls._validate_locked_agent_soul_not_overridden(binding=binding, node_job=node_job) + + output_names: set[str] = set() + for output in node_job.declared_outputs: + if output.name in output_names: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} has duplicate output name {output.name}." + ) + output_names.add(output.name) + for check in output.checks: + if check.benchmark_file_ref is not None: + cls._validate_file_ref( + session=session, + binding=binding, + file_ref=check.benchmark_file_ref, + ref_context=f"output {output.name} benchmark file", + ) + + for ref in node_job.previous_node_output_refs: + selector = cls.selector_from_ref(ref) + if selector is None: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} has invalid previous node output ref." + ) + if topology is None: + continue + if len(selector) < 2: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} has incomplete previous node output ref." + ) + source_node_id = selector[0] + if not topology.has_node(source_node_id): + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references missing previous node {source_node_id}." + ) + if not topology.is_upstream(source_node_id=source_node_id, target_node_id=binding.node_id): + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references non-upstream previous node {source_node_id}." + ) + + for human_ref in node_job.human_contacts: + cls._validate_human_ref(binding=binding, human_ref=human_ref) + + file_refs = node_job.metadata.get("file_refs") + if isinstance(file_refs, list): + for file_ref in file_refs: + if isinstance(file_ref, Mapping): + cls._validate_file_ref( + session=session, + binding=binding, + file_ref=file_ref, + ref_context="metadata file ref", + ) + + @staticmethod + def iter_agent_v2_nodes(graph_dict: Mapping[str, Any]) -> Iterator[tuple[str, Mapping[str, Any]]]: + nodes = graph_dict.get("nodes") + if not isinstance(nodes, list): + return + for node in nodes: + if not isinstance(node, Mapping): + continue + node_id = node.get("id") + node_data = node.get("data") + if not isinstance(node_id, str) or not isinstance(node_data, Mapping): + continue + if node_data.get("type") == BuiltinNodeTypes.AGENT and str(node_data.get("version")) == "2": + yield node_id, node_data + + @staticmethod + def selector_from_ref(ref: Mapping[str, Any]) -> list[str] | None: + for key in ("selector", "variable_selector", "value_selector"): + value = ref.get(key) + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return value + node_id = ref.get("node_id") + output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key") + if isinstance(node_id, str) and isinstance(output_name, str): + return [node_id, output_name] + return None + + @staticmethod + def _validate_node_schema(*, node_id: str, node_data: Mapping[str, Any]) -> None: + try: + DifyAgentNodeData.model_validate(node_data) + except ValueError as exc: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {node_id} has invalid Agent v2 node schema: {exc}" + ) from exc + + @classmethod + def _validate_locked_agent_soul_not_overridden( + cls, + *, + binding: WorkflowAgentNodeBinding, + node_job: WorkflowNodeJobConfig, + ) -> None: + forbidden_paths = cls._find_locked_agent_soul_paths(node_job.metadata) + if forbidden_paths: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} cannot override locked Agent Soul fields: " + f"{', '.join(sorted(forbidden_paths))}." + ) + + @classmethod + def _find_locked_agent_soul_paths(cls, value: Any, *, path: str = "metadata") -> set[str]: + if not isinstance(value, Mapping): + return set() + forbidden: set[str] = set() + for key, item in value.items(): + key_text = str(key) + if key_text in cls._LOCKED_AGENT_SOUL_KEYS: + forbidden.add(f"{path}.{key_text}") + forbidden.update(cls._find_locked_agent_soul_paths(item, path=f"{path}.{key_text}")) + return forbidden + + @classmethod + def _validate_human_ref( + cls, + *, + binding: WorkflowAgentNodeBinding, + human_ref: Mapping[str, Any], + ) -> None: + contact_id = human_ref.get("contact_id") or human_ref.get("human_id") or human_ref.get("id") + if not isinstance(contact_id, str) or not contact_id: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} has invalid human contact ref." + ) + + tenant_id = human_ref.get("tenant_id") + if tenant_id is not None and tenant_id != binding.tenant_id: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references out-of-scope human contact {contact_id}." + ) + + channel = human_ref.get("channel") or human_ref.get("method") or human_ref.get("contact_method") + if channel is not None and channel not in cls._SUPPORTED_HUMAN_CONTACT_CHANNELS: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references unsupported human contact channel {channel}." + ) + + @staticmethod + def _validate_file_ref( + *, + session: Session, + binding: WorkflowAgentNodeBinding, + file_ref: Mapping[str, Any], + ref_context: str, + ) -> None: + tenant_id = file_ref.get("tenant_id") + if tenant_id is not None and tenant_id != binding.tenant_id: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references out-of-scope {ref_context}." + ) + + upload_file_id = ( + file_ref.get("upload_file_id") or file_ref.get("file_id") or file_ref.get("id") or file_ref.get("reference") + ) + if upload_file_id is None and (file_ref.get("url") or file_ref.get("remote_url")): + return + if not isinstance(upload_file_id, str) or not upload_file_id: + raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} has invalid {ref_context}.") + + upload_file = session.scalar( + select(UploadFile) + .where( + UploadFile.tenant_id == binding.tenant_id, + UploadFile.id == upload_file_id, + ) + .limit(1) + ) + if upload_file is None: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references missing or out-of-scope {ref_context}." + ) + + @staticmethod + def _find_binding( + *, + session: Session, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + ) -> WorkflowAgentNodeBinding | None: + return session.scalar( + select(WorkflowAgentNodeBinding) + .where( + WorkflowAgentNodeBinding.tenant_id == tenant_id, + WorkflowAgentNodeBinding.app_id == app_id, + WorkflowAgentNodeBinding.workflow_id == workflow_id, + WorkflowAgentNodeBinding.node_id == node_id, + ) + .limit(1) + ) + + +class _WorkflowGraphTopology: + def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None: + self._node_ids = node_ids + self._incoming = incoming + + @classmethod + def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology: + node_ids = cls._node_ids_from_graph(graph) + incoming: dict[str, list[str]] = defaultdict(list) + edges = graph.get("edges") + if isinstance(edges, list): + for edge in edges: + if not isinstance(edge, Mapping): + continue + source = edge.get("source") + target = edge.get("target") + if isinstance(source, str) and isinstance(target, str): + incoming[target].append(source) + return cls(node_ids=node_ids, incoming=incoming) + + def has_node(self, node_id: str) -> bool: + return node_id in self._node_ids + + def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool: + if source_node_id == target_node_id: + return False + visited: set[str] = set() + queue: deque[str] = deque(self._incoming.get(target_node_id, ())) + while queue: + candidate = queue.popleft() + if candidate == source_node_id: + return True + if candidate in visited: + continue + visited.add(candidate) + queue.extend(self._incoming.get(candidate, ())) + return False + + @staticmethod + def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]: + node_ids: set[str] = set() + nodes = graph.get("nodes") + if not isinstance(nodes, list): + return node_ids + for node in nodes: + if not isinstance(node, Mapping): + continue + node_id = node.get("id") + if isinstance(node_id, str): + node_ids.add(node_id) + return node_ids diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index 9f7f73765e..af0d77411b 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -457,14 +457,16 @@ def init_app(app: DifyApp): def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol: assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here." + join_timeout_ms = dify_config.PUBSUB_LISTENER_JOIN_TIMEOUT_MS if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded": - return ShardedRedisBroadcastChannel(_pubsub_redis_client) + return ShardedRedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms) if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "streams": return StreamsBroadcastChannel( _pubsub_redis_client, retention_seconds=dify_config.PUBSUB_STREAMS_RETENTION_SECONDS, + join_timeout_ms=join_timeout_ms, ) - return RedisBroadcastChannel(_pubsub_redis_client) + return RedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms) def redis_fallback[T](default_return: T | None = None): # type: ignore diff --git a/api/libs/broadcast_channel/redis/_subscription.py b/api/libs/broadcast_channel/redis/_subscription.py index 4db79a15a9..9fe50445e4 100644 --- a/api/libs/broadcast_channel/redis/_subscription.py +++ b/api/libs/broadcast_channel/redis/_subscription.py @@ -26,6 +26,8 @@ class RedisSubscriptionBase(Subscription): client: Redis | RedisCluster, pubsub: PubSub, topic: str, + *, + join_timeout_ms: int = 2000, ): # The _pubsub is None only if the subscription is closed. self._client = client @@ -37,6 +39,11 @@ class RedisSubscriptionBase(Subscription): self._listener_thread: threading.Thread | None = None self._start_lock = threading.Lock() self._started = False + # Max time close() will wait for the listener thread to finish before + # returning. Bounds SSE close tail latency. The listener is a daemon + # and exits on its own within one poll window (~1s), so a low value + # here just means close() returns sooner without breaking anything. + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def _start_if_needed(self) -> None: """Start the subscription if not already started.""" @@ -205,7 +212,7 @@ class RedisSubscriptionBase(Subscription): # Due to the restriction above, the PubSub cleanup logic happens inside the consumer thread. listener = self._listener_thread if listener is not None: - listener.join(timeout=1.0) + listener.join(timeout=self._join_timeout_ms / 1000.0) self._listener_thread = None # Abstract methods to be implemented by subclasses diff --git a/api/libs/broadcast_channel/redis/channel.py b/api/libs/broadcast_channel/redis/channel.py index b76a23eb3c..7f13ebaabc 100644 --- a/api/libs/broadcast_channel/redis/channel.py +++ b/api/libs/broadcast_channel/redis/channel.py @@ -22,18 +22,30 @@ class BroadcastChannel: def __init__( self, redis_client: Redis | RedisCluster, + *, + join_timeout_ms: int = 2000, ): self._client = redis_client + # See `RedisSubscriptionBase._join_timeout_ms`: how long close() + # waits for the listener thread before returning. + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def topic(self, topic: str) -> Topic: - return Topic(self._client, topic) + return Topic(self._client, topic, join_timeout_ms=self._join_timeout_ms) class Topic: - def __init__(self, redis_client: Redis | RedisCluster, topic: str): + def __init__( + self, + redis_client: Redis | RedisCluster, + topic: str, + *, + join_timeout_ms: int = 2000, + ): self._client = redis_client self._topic = topic self._redis_topic = serialize_redis_name(topic) + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def as_producer(self) -> Producer: return self @@ -49,6 +61,7 @@ class Topic: client=self._client, pubsub=self._client.pubsub(), topic=self._redis_topic, + join_timeout_ms=self._join_timeout_ms, ) diff --git a/api/libs/broadcast_channel/redis/sharded_channel.py b/api/libs/broadcast_channel/redis/sharded_channel.py index 919d8d622e..02dc987107 100644 --- a/api/libs/broadcast_channel/redis/sharded_channel.py +++ b/api/libs/broadcast_channel/redis/sharded_channel.py @@ -20,18 +20,28 @@ class ShardedRedisBroadcastChannel: def __init__( self, redis_client: Redis | RedisCluster, + *, + join_timeout_ms: int = 2000, ): self._client = redis_client + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def topic(self, topic: str) -> ShardedTopic: - return ShardedTopic(self._client, topic) + return ShardedTopic(self._client, topic, join_timeout_ms=self._join_timeout_ms) class ShardedTopic: - def __init__(self, redis_client: Redis | RedisCluster, topic: str): + def __init__( + self, + redis_client: Redis | RedisCluster, + topic: str, + *, + join_timeout_ms: int = 2000, + ): self._client = redis_client self._topic = topic self._redis_topic = serialize_redis_name(topic) + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def as_producer(self) -> Producer: return self @@ -47,6 +57,7 @@ class ShardedTopic: client=self._client, pubsub=self._client.pubsub(), topic=self._redis_topic, + join_timeout_ms=self._join_timeout_ms, ) diff --git a/api/libs/broadcast_channel/redis/streams_channel.py b/api/libs/broadcast_channel/redis/streams_channel.py index 55ff6cd4f9..985b253c7c 100644 --- a/api/libs/broadcast_channel/redis/streams_channel.py +++ b/api/libs/broadcast_channel/redis/streams_channel.py @@ -24,20 +24,42 @@ class StreamsBroadcastChannel: - The stream key expires `retention_seconds` after the last event is published (to bound storage). """ - def __init__(self, redis_client: Redis | RedisCluster, *, retention_seconds: int = 600): + def __init__( + self, + redis_client: Redis | RedisCluster, + *, + retention_seconds: int = 600, + join_timeout_ms: int = 2000, + ): self._client = redis_client self._retention_seconds = max(int(retention_seconds or 0), 0) + # Max time close() will wait for the listener thread to finish. + # See `_StreamsSubscription._join_timeout_ms` for the rationale. + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def topic(self, topic: str) -> StreamsTopic: - return StreamsTopic(self._client, topic, retention_seconds=self._retention_seconds) + return StreamsTopic( + self._client, + topic, + retention_seconds=self._retention_seconds, + join_timeout_ms=self._join_timeout_ms, + ) class StreamsTopic: - def __init__(self, redis_client: Redis | RedisCluster, topic: str, *, retention_seconds: int = 600): + def __init__( + self, + redis_client: Redis | RedisCluster, + topic: str, + *, + retention_seconds: int = 600, + join_timeout_ms: int = 2000, + ): self._client = redis_client self._topic = topic self._key = serialize_redis_name(f"stream:{topic}") self._retention_seconds = retention_seconds + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) self.max_length = 5000 def as_producer(self) -> Producer: @@ -55,15 +77,23 @@ class StreamsTopic: return self def subscribe(self) -> Subscription: - return _StreamsSubscription(self._client, self._key) + return _StreamsSubscription(self._client, self._key, join_timeout_ms=self._join_timeout_ms) class _StreamsSubscription(Subscription): _SENTINEL = object() - def __init__(self, client: Redis | RedisCluster, key: str): + def __init__(self, client: Redis | RedisCluster, key: str, *, join_timeout_ms: int = 2000): self._client = client self._key = key + # Max time close() will wait for the listener thread to finish before + # returning. Bounds SSE close tail latency: the listener blocks on + # XREAD with BLOCK=1000ms, so close() naturally waits up to ~1s for + # the thread to notice _closed. Setting this lower lets close() + # return promptly while the daemon listener exits on its own within + # one BLOCK window - safe because the listener holds no critical + # state. ``0`` means close() does not wait at all. + self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) self._queue: queue.Queue[object] = queue.Queue() @@ -181,11 +211,13 @@ class _StreamsSubscription(Subscription): # We close the listener outside of the with block to avoid holding the # lock for a long time. if listener is not None and listener.is_alive(): - listener.join(timeout=2.0) + listener.join(timeout=self._join_timeout_ms / 1000.0) if listener.is_alive(): - logger.warning( - "Streams subscription listener for key %s did not stop within timeout; keeping reference.", + logger.debug( + "Streams subscription listener for key %s did not stop within %dms; " + "daemon thread will exit on its own within one poll window.", self._key, + self._join_timeout_ms, ) # Context manager helpers diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 2044d48e40..c07f51b261 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -64,6 +64,24 @@ class AgentSoulMemoryConfig(BaseModel): artifacts: list[dict[str, Any]] = Field(default_factory=list) +class AgentSoulModelCredentialRef(BaseModel): + """Reference to model credentials resolved only at runtime.""" + + type: str = Field(min_length=1, max_length=64) + id: str | None = Field(default=None, max_length=255) + provider: str | None = Field(default=None, max_length=255) + + +class AgentSoulModelConfig(BaseModel): + """Stable model selection for Agent runtime without storing secret values.""" + + plugin_id: str = Field(min_length=1, max_length=255) + model_provider: str = Field(min_length=1, max_length=255) + model: str = Field(min_length=1, max_length=255) + credential_ref: AgentSoulModelCredentialRef | None = None + model_settings: dict[str, Any] = Field(default_factory=dict) + + class AppVariableConfig(BaseModel): name: str = Field(min_length=1, max_length=255) type: str = Field(min_length=1, max_length=64) @@ -83,6 +101,7 @@ class AgentSoulConfig(BaseModel): env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig) sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig) memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig) + model: AgentSoulModelConfig | None = None app_features: dict[str, Any] = Field(default_factory=dict) app_variables: list[AppVariableConfig] = Field(default_factory=list) misc_legacy: dict[str, Any] = Field(default_factory=dict) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 0d688b40ac..33f330f30c 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -10515,6 +10515,7 @@ Supported icon storage formats for Agent roster entries. | knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No | | memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No | | misc_legacy | object | | No | +| model | [AgentSoulModelConfig](#agentsoulmodelconfig) | | No | | prompt | [AgentSoulPromptConfig](#agentsoulpromptconfig) | | No | | sandbox | [AgentSoulSandboxConfig](#agentsoulsandboxconfig) | | No | | schema_version | integer | | No | @@ -10551,6 +10552,28 @@ Supported icon storage formats for Agent roster entries. | budget | string | | No | | scope | string | | No | +#### AgentSoulModelConfig + +Stable model selection for Agent runtime without storing secret values. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_ref | [AgentSoulModelCredentialRef](#agentsoulmodelcredentialref) | | No | +| model | string | | Yes | +| model_provider | string | | Yes | +| model_settings | object | | No | +| plugin_id | string | | Yes | + +#### AgentSoulModelCredentialRef + +Reference to model credentials resolved only at runtime. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | No | +| provider | string | | No | +| type | string | | Yes | + #### AgentSoulPromptConfig | Name | Type | Description | Required | diff --git a/api/providers/trace/trace-arize-phoenix/pyproject.toml b/api/providers/trace/trace-arize-phoenix/pyproject.toml index 9e756944c9..b9a10e8388 100644 --- a/api/providers/trace/trace-arize-phoenix/pyproject.toml +++ b/api/providers/trace/trace-arize-phoenix/pyproject.toml @@ -2,7 +2,7 @@ name = "dify-trace-arize-phoenix" version = "0.0.1" dependencies = [ - "arize-phoenix-otel~=0.15.0", + "arize-phoenix-otel==0.15.0", ] description = "Dify ops tracing provider (Arize / Phoenix)." diff --git a/api/providers/trace/trace-langsmith/pyproject.toml b/api/providers/trace/trace-langsmith/pyproject.toml index 2ca7ff49c5..80eb9ae323 100644 --- a/api/providers/trace/trace-langsmith/pyproject.toml +++ b/api/providers/trace/trace-langsmith/pyproject.toml @@ -2,7 +2,7 @@ name = "dify-trace-langsmith" version = "0.0.1" dependencies = [ - "langsmith>=0.8.0", + "langsmith==0.8.5", ] description = "Dify ops tracing provider (LangSmith)." diff --git a/api/providers/trace/trace-mlflow/pyproject.toml b/api/providers/trace/trace-mlflow/pyproject.toml index fad6002944..a72507d877 100644 --- a/api/providers/trace/trace-mlflow/pyproject.toml +++ b/api/providers/trace/trace-mlflow/pyproject.toml @@ -2,7 +2,7 @@ name = "dify-trace-mlflow" version = "0.0.1" dependencies = [ - "mlflow-skinny>=3.11.1", + "mlflow-skinny>=3.11.1,<4.0.0", ] description = "Dify ops tracing provider (MLflow / Databricks)." diff --git a/api/providers/trace/trace-weave/pyproject.toml b/api/providers/trace/trace-weave/pyproject.toml index ba449f2a93..8225cdbf56 100644 --- a/api/providers/trace/trace-weave/pyproject.toml +++ b/api/providers/trace/trace-weave/pyproject.toml @@ -2,7 +2,7 @@ name = "dify-trace-weave" version = "0.0.1" dependencies = [ - "weave>=0.52.36", + "weave==0.52.36", ] description = "Dify ops tracing provider (Weave)." diff --git a/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml b/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml index bbc0e06ffa..9103f3e4f1 100644 --- a/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml +++ b/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml @@ -2,7 +2,7 @@ name = "dify-vdb-alibabacloud-mysql" version = "0.0.1" dependencies = [ - "mysql-connector-python>=9.3.0", + "mysql-connector-python>=9.3.0,<10.0.0", ] description = "Dify vector store backend (dify-vdb-alibabacloud-mysql)." diff --git a/api/providers/vdb/vdb-analyticdb/pyproject.toml b/api/providers/vdb/vdb-analyticdb/pyproject.toml index af5def3061..f22e3e8e12 100644 --- a/api/providers/vdb/vdb-analyticdb/pyproject.toml +++ b/api/providers/vdb/vdb-analyticdb/pyproject.toml @@ -3,8 +3,8 @@ name = "dify-vdb-analyticdb" version = "0.0.1" dependencies = [ "alibabacloud_gpdb20160503~=5.2.0", - "alibabacloud_tea_openapi~=0.4.3", - "clickhouse-connect~=0.15.0", + "alibabacloud_tea_openapi==0.4.4", + "clickhouse-connect==0.15.1", ] description = "Dify vector store backend (dify-vdb-analyticdb)." diff --git a/api/providers/vdb/vdb-clickzetta/pyproject.toml b/api/providers/vdb/vdb-clickzetta/pyproject.toml index aea94fdb2a..fd82088cb4 100644 --- a/api/providers/vdb/vdb-clickzetta/pyproject.toml +++ b/api/providers/vdb/vdb-clickzetta/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-clickzetta" version = "0.0.1" dependencies = [ - "clickzetta-connector-python>=0.8.102", + "clickzetta-connector-python==0.8.104", ] description = "Dify vector store backend (dify-vdb-clickzetta)." diff --git a/api/providers/vdb/vdb-hologres/pyproject.toml b/api/providers/vdb/vdb-hologres/pyproject.toml index 88044bf6d6..bc6cfed04e 100644 --- a/api/providers/vdb/vdb-hologres/pyproject.toml +++ b/api/providers/vdb/vdb-hologres/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-hologres" version = "0.0.1" dependencies = [ - "holo-search-sdk>=0.4.2", + "holo-search-sdk==0.4.2", ] description = "Dify vector store backend (dify-vdb-hologres)." diff --git a/api/providers/vdb/vdb-iris/pyproject.toml b/api/providers/vdb/vdb-iris/pyproject.toml index 6dd7a8e073..c4da985032 100644 --- a/api/providers/vdb/vdb-iris/pyproject.toml +++ b/api/providers/vdb/vdb-iris/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-iris" version = "0.0.1" dependencies = [ - "intersystems-irispython>=5.1.0", + "intersystems-irispython>=5.1.0,<6.0.0", ] description = "Dify vector store backend (dify-vdb-iris)." diff --git a/api/providers/vdb/vdb-lindorm/pyproject.toml b/api/providers/vdb/vdb-lindorm/pyproject.toml index 0cffc67491..33268cc981 100644 --- a/api/providers/vdb/vdb-lindorm/pyproject.toml +++ b/api/providers/vdb/vdb-lindorm/pyproject.toml @@ -4,7 +4,7 @@ version = "0.0.1" dependencies = [ "opensearch-py==3.1.0", - "tenacity>=8.0.0", + "tenacity>=8.0.0,<9.0.0", ] description = "Dify vector store backend (dify-vdb-lindorm)." diff --git a/api/providers/vdb/vdb-matrixone/pyproject.toml b/api/providers/vdb/vdb-matrixone/pyproject.toml index 53363ed7d9..e87b9f2ec2 100644 --- a/api/providers/vdb/vdb-matrixone/pyproject.toml +++ b/api/providers/vdb/vdb-matrixone/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-matrixone" version = "0.0.1" dependencies = [ - "mo-vector~=0.1.13", + "mo-vector==0.1.13", ] description = "Dify vector store backend (dify-vdb-matrixone)." diff --git a/api/providers/vdb/vdb-myscale/pyproject.toml b/api/providers/vdb/vdb-myscale/pyproject.toml index 13e0f35d23..895d498ba7 100644 --- a/api/providers/vdb/vdb-myscale/pyproject.toml +++ b/api/providers/vdb/vdb-myscale/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-myscale" version = "0.0.1" dependencies = [ - "clickhouse-connect~=0.15.0", + "clickhouse-connect==0.15.1", ] description = "Dify vector store backend (dify-vdb-myscale)." diff --git a/api/providers/vdb/vdb-oceanbase/pyproject.toml b/api/providers/vdb/vdb-oceanbase/pyproject.toml index 887869a41c..7888c89724 100644 --- a/api/providers/vdb/vdb-oceanbase/pyproject.toml +++ b/api/providers/vdb/vdb-oceanbase/pyproject.toml @@ -3,8 +3,8 @@ name = "dify-vdb-oceanbase" version = "0.0.1" dependencies = [ - "pyobvector~=0.2.17", - "mysql-connector-python>=9.3.0", + "pyobvector==0.2.25", + "mysql-connector-python>=9.3.0,<10.0.0", ] description = "Dify vector store backend (dify-vdb-oceanbase)." diff --git a/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml b/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml index 9a25442e9e..d1e25a31ca 100644 --- a/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml +++ b/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-pgvecto-rs" version = "0.0.1" dependencies = [ - "pgvecto-rs[sqlalchemy]~=0.2.2", + "pgvecto-rs[sqlalchemy]==0.2.2", ] description = "Dify vector store backend (dify-vdb-pgvecto-rs)." diff --git a/api/providers/vdb/vdb-vastbase/pyproject.toml b/api/providers/vdb/vdb-vastbase/pyproject.toml index 287eb147dc..8fccc3b423 100644 --- a/api/providers/vdb/vdb-vastbase/pyproject.toml +++ b/api/providers/vdb/vdb-vastbase/pyproject.toml @@ -3,7 +3,7 @@ name = "dify-vdb-vastbase" version = "0.0.1" dependencies = [ - "pyobvector~=0.2.17", + "pyobvector==0.2.25", ] description = "Dify vector store backend (dify-vdb-vastbase)." diff --git a/api/pyproject.toml b/api/pyproject.toml index aa1af71b4c..1920a9f4de 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -5,48 +5,48 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed - "bleach>=6.3.0", - "boto3>=1.43.10", - "celery>=5.6.3", - "croniter>=6.2.2", + "bleach>=6.3.0,<7.0.0", + "boto3>=1.43.10,<2.0.0", + "celery>=5.6.3,<6.0.0", + "croniter>=6.2.2,<7.0.0", "dify-agent", "flask>=3.1.3,<4.0.0", - "flask-cors>=6.0.2", - "gevent>=26.4.0", - "gevent-websocket>=0.10.1", - "gmpy2>=2.3.0", - "google-api-python-client>=2.196.0", - "gunicorn>=26.0.0", - "psycogreen>=1.0.2", - "psycopg2-binary>=2.9.12", - "python-socketio>=5.13.0", - "redis[hiredis]>=7.4.0", - "sendgrid>=6.12.5", - "sseclient-py>=1.8.0", + "flask-cors>=6.0.2,<7.0.0", + "gevent>=26.4.0,<26.5.0", + "gevent-websocket==0.10.1", + "gmpy2>=2.3.0,<3.0.0", + "google-api-python-client>=2.196.0,<3.0.0", + "gunicorn>=26.0.0,<27.0.0", + "psycogreen>=1.0.2,<2.0.0", + "psycopg2-binary>=2.9.12,<3.0.0", + "python-socketio>=5.13.0,<6.0.0", + "redis[hiredis]>=7.4.0,<8.0.0", + "sendgrid>=6.12.5,<7.0.0", + "sseclient-py>=1.8.0,<2.0.0", # Stable: production-proven, cap below the next major - "aliyun-log-python-sdk>=0.9.44,<1.0.0", + "aliyun-log-python-sdk==0.9.44", "azure-identity>=1.25.3,<2.0.0", "flask-compress>=1.24,<2.0.0", - "flask-login>=0.6.3,<1.0.0", + "flask-login==0.6.3", "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", "google-cloud-aiplatform>=1.151.0,<2.0.0", - "httpx[socks]>=0.28.1,<1.0.0", - "opentelemetry-distro>=0.62b1,<1.0.0", - "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", - "opentelemetry-instrumentation-flask>=0.62b0,<1.0.0", - "opentelemetry-instrumentation-httpx>=0.62b0,<1.0.0", - "opentelemetry-instrumentation-redis>=0.62b0,<1.0.0", - "opentelemetry-instrumentation-sqlalchemy>=0.62b0,<1.0.0", + "httpx[socks]==0.28.1", + "opentelemetry-distro==0.62b1", + "opentelemetry-instrumentation-celery==0.62b1", + "opentelemetry-instrumentation-flask==0.62b1", + "opentelemetry-instrumentation-httpx==0.62b1", + "opentelemetry-instrumentation-redis==0.62b1", + "opentelemetry-instrumentation-sqlalchemy==0.62b1", "opentelemetry-propagator-b3>=1.41.1,<2.0.0", - "readabilipy>=0.3.0,<1.0.0", + "readabilipy==0.3.0", "resend>=2.27.0,<3.0.0", # Emerging: newer and fast-moving, use compatible pins - "fastopenapi[flask]~=0.7.0", - "graphon~=0.4.0", - "httpx-sse~=0.4.0", - "json-repair~=0.59.4", + "fastopenapi[flask]==0.7.0", + "graphon==0.4.0", + "httpx-sse==0.4.3", + "json-repair==0.59.4", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. @@ -103,8 +103,8 @@ dify-trace-weave = { workspace = true } default-groups = ["storage", "tools", "vdb-all", "trace-all"] package = false override-dependencies = [ - "litellm>=1.83.10", - "pyarrow>=18.0.0", + "litellm>=1.83.10,<2.0.0", + "pyarrow>=23.0.1,<24.0.0", ] [dependency-groups] @@ -183,21 +183,21 @@ dev = [ # Required for storage clients ############################################################ storage = [ - "azure-storage-blob>=12.29.0", - "bce-python-sdk>=0.9.71", - "cos-python-sdk-v5>=1.9.43", - "esdk-obs-python>=3.22.2", - "google-cloud-storage>=3.10.1", - "opendal>=0.46.0", - "oss2>=2.19.1", - "supabase>=2.30.0", - "tos>=2.9.0", + "azure-storage-blob>=12.29.0,<13.0.0", + "bce-python-sdk==0.9.71", + "cos-python-sdk-v5>=1.9.43,<2.0.0", + "esdk-obs-python>=3.22.2,<4.0.0", + "google-cloud-storage>=3.10.1,<4.0.0", + "opendal==0.46.0", + "oss2>=2.19.1,<3.0.0", + "supabase>=2.30.0,<3.0.0", + "tos>=2.9.0,<3.0.0", ] ############################################################ # [ Tools ] dependency group ############################################################ -tools = ["cloudscraper>=1.2.71", "nltk>=3.9.1"] +tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"] ############################################################ # [ VDB ] workspace plugins — hollow packages under providers/vdb/* @@ -267,7 +267,7 @@ vdb-vastbase = ["dify-vdb-vastbase"] vdb-vikingdb = ["dify-vdb-vikingdb"] vdb-weaviate = ["dify-vdb-weaviate"] # Optional client used by some tests / integrations (not a vector backend plugin) -vdb-xinference = ["xinference-client>=2.7.0"] +vdb-xinference = ["xinference-client>=2.7.0,<3.0.0"] trace-all = [ "dify-trace-aliyun", diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py new file mode 100644 index 0000000000..06985dc3fa --- /dev/null +++ b/api/services/agent/workflow_publish_service.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator +from models.agent import WorkflowAgentNodeBinding +from models.agent_config_entities import WorkflowNodeJobConfig +from models.workflow import Workflow + + +class WorkflowAgentPublishService: + """Validate and freeze Workflow Agent v2 bindings during workflow publish.""" + + @classmethod + def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: + WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=draft_workflow) + + @classmethod + def validate_agent_nodes_for_draft_sync(cls, *, session: Session, draft_workflow: Workflow) -> None: + WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow) + + @classmethod + def copy_agent_node_bindings_to_published( + cls, + *, + session: Session, + draft_workflow: Workflow, + published_workflow: Workflow, + ) -> None: + node_ids = { + node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict) + } + if not node_ids: + return + + bindings = session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.node_id.in_(node_ids), + ) + ).all() + + for binding in bindings: + copied = WorkflowAgentNodeBinding( + tenant_id=binding.tenant_id, + app_id=binding.app_id, + workflow_id=published_workflow.id, + node_id=binding.node_id, + binding_type=binding.binding_type, + agent_id=binding.agent_id, + current_snapshot_id=binding.current_snapshot_id, + node_job_config=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), + created_by=binding.created_by, + updated_by=binding.updated_by, + ) + session.add(copied) diff --git a/api/services/message_service.py b/api/services/message_service.py index 8f5e028d4d..e8d1b6232b 100644 --- a/api/services/message_service.py +++ b/api/services/message_service.py @@ -2,7 +2,6 @@ import logging from collections.abc import Sequence from typing import cast -from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import sessionmaker @@ -23,7 +22,6 @@ from models.model import ( App, AppMode, AppModelConfig, - AppModelConfigDict, EndUser, Message, MessageFeedback, @@ -42,7 +40,6 @@ from services.errors.message import ( ) from services.workflow_service import WorkflowService -_app_model_config_adapter: TypeAdapter[AppModelConfigDict] = TypeAdapter(AppModelConfigDict) logger = logging.getLogger(__name__) @@ -297,14 +294,12 @@ class MessageService: .limit(1) ) else: - conversation_override_model_configs = _app_model_config_adapter.validate_json( - conversation.override_model_configs - ) app_model_config = AppModelConfig( app_id=app_model.id, ) - app_model_config.id = conversation.app_model_config_id - app_model_config = app_model_config.from_model_config_dict(conversation_override_model_configs) + # Reuse Conversation.model_config so suggested-questions reads the same + # compatibility-normalized config as the rest of the message flow. + app_model_config = app_model_config.from_model_config_dict(conversation.model_config) if not app_model_config: raise ValueError("did not find app model config") diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 1b0e10d784..6d9ee97fa4 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -312,6 +312,13 @@ class WorkflowService: workflow.environment_variables = environment_variables workflow.conversation_variables = conversation_variables + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( + session=cast(Session, db.session), + draft_workflow=workflow, + ) + # commit db session changes db.session.commit() @@ -457,6 +464,13 @@ class WorkflowService: # validate graph structure self.validate_graph_structure(graph=draft_workflow.graph_dict) + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + WorkflowAgentPublishService.validate_agent_nodes_for_publish( + session=session, + draft_workflow=draft_workflow, + ) + # billing check if dify_config.BILLING_ENABLED: limit_info = BillingService.get_info(app_model.tenant_id) @@ -490,6 +504,11 @@ class WorkflowService: # commit db session changes session.add(workflow) + WorkflowAgentPublishService.copy_agent_node_bindings_to_published( + session=session, + draft_workflow=draft_workflow, + published_workflow=workflow, + ) # trigger app workflow events app_published_workflow_was_updated.send(app_model, published_workflow=workflow) diff --git a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py index 835c9a8576..9b89b10820 100644 --- a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py @@ -11,8 +11,10 @@ from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.app.task_pipeline import message_cycle_manager from core.app.task_pipeline.message_cycle_manager import MessageCycleManager +from core.ops.ops_trace_manager import TraceQueueManager from models.enums import ConversationFromSource from models.model import AppMode, Conversation, Message +from services.errors.conversation import ConversationNotExistsError def _make_app_config() -> WorkflowUIBasedAppConfig: @@ -108,6 +110,75 @@ def test_init_generate_records_marks_existing_conversation(): assert entity.is_new_conversation is False +def test_generate_falls_back_to_new_conversation_when_conversation_missing(monkeypatch: pytest.MonkeyPatch): + app_config = _make_app_config() + workflow = SimpleNamespace( + features_dict={}, + tenant_id="tenant-id", + app_id="app-id", + id="workflow-id", + ) + app_model = SimpleNamespace(id="app-id", tenant_id="tenant-id") + user = SimpleNamespace(id="user-id", session_id="session-id") + + def raise_conversation_not_exists(**_kwargs): + raise ConversationNotExistsError() + + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.ConversationService.get_conversation", + raise_conversation_not_exists, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + lambda **_kwargs: app_config, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=object()), + ) + trace_manager = object.__new__(TraceQueueManager) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + lambda **_kwargs: trace_manager, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **_kwargs: SimpleNamespace(), + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **_kwargs: SimpleNamespace(), + ) + + captured: dict[str, object] = {} + + def fake_generate(self, **kwargs): + captured.update(kwargs) + return {"status": "ok"} + + monkeypatch.setattr(AdvancedChatAppGenerator, "_generate", fake_generate) + + result = AdvancedChatAppGenerator().generate( + app_model=app_model, + workflow=workflow, + user=user, + args={"inputs": {}, "query": "hello", "conversation_id": "missing-conversation-id"}, + invoke_from=InvokeFrom.SERVICE_API, + workflow_run_id="workflow-run-id", + streaming=False, + ) + + assert result == {"status": "ok"} + assert captured["conversation"] is None + application_generate_entity = captured["application_generate_entity"] + assert isinstance(application_generate_entity, AdvancedChatAppGenerateEntity) + assert application_generate_entity.conversation_id is None + + def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch: pytest.MonkeyPatch): app_config = _make_app_config() entity = _make_generate_entity(app_config) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py new file mode 100644 index 0000000000..5a0cf87688 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -0,0 +1,155 @@ +from types import SimpleNamespace +from typing import cast + +from clients.agent_backend import ( + AgentBackendRunEventAdapter, + AgentBackendStreamInternalEvent, + FakeAgentBackendRunClient, + FakeAgentBackendScenario, +) +from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom +from core.workflow.nodes.agent_v2 import DifyAgentNode +from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingBundle, WorkflowAgentBindingResolver +from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData +from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter +from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder +from graphon.entities import GraphInitParams +from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from graphon.node_events import StreamCompletedEvent +from graphon.runtime import GraphRuntimeState +from graphon.variables.segments import StringSegment +from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding +from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig + + +class FakeCredentialsProvider: + def fetch(self, provider_name: str, model_name: str) -> dict[str, object]: + assert provider_name == "openai" + assert model_name == "gpt-test" + return {"api_key": "secret-key"} + + +class FakeVariablePool: + def get(self, selector): + values = { + ("sys", "query"): "Summarize the report.", + ("sys", "workflow_run_id"): "workflow-run-1", + ("sys", "conversation_id"): "conversation-1", + ("previous-node", "text"): "Previous result", + } + value = values.get(tuple(selector)) + if value is None: + return None + return StringSegment(value=value) + + def get_by_prefix(self, prefix): + return {} + + +class FakeBindingResolver(WorkflowAgentBindingResolver): + def __init__(self): + self.agent = Agent(id="agent-1", tenant_id="tenant-1", name="Agent") + self.snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + prompt={"system_prompt": "You are careful."}, + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + ), + ) + self.binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig.model_validate( + { + "workflow_prompt": "Use the previous output.", + "previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}], + "declared_outputs": [{"name": "text", "type": "string"}], + } + ), + ) + + def resolve(self, **_kwargs): + return WorkflowAgentBindingBundle(binding=self.binding, agent=self.agent, snapshot=self.snapshot) + + +def _node(*, scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS) -> DifyAgentNode: + graph_init_params = GraphInitParams( + workflow_id="workflow-1", + graph_config={"nodes": [], "edges": []}, + run_context={ + DIFY_RUN_CONTEXT_KEY: DifyRunContext( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + }, + call_depth=0, + ) + return DifyAgentNode( + node_id="agent-node", + data=DifyAgentNodeData.model_validate({"type": BuiltinNodeTypes.AGENT, "version": "2"}), + graph_init_params=graph_init_params, + graph_runtime_state=cast(GraphRuntimeState, SimpleNamespace(variable_pool=FakeVariablePool())), + binding_resolver=FakeBindingResolver(), + runtime_request_builder=WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()), + agent_backend_client=FakeAgentBackendRunClient(scenario=scenario), + event_adapter=AgentBackendRunEventAdapter(), + output_adapter=WorkflowAgentOutputAdapter(), + ) + + +def test_agent_node_run_maps_successful_agent_backend_run_to_node_result(): + events = list(_node()._run()) + + assert len(events) == 1 + result = cast(StreamCompletedEvent, events[0]).node_run_result + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs == {"text": "hello agent"} + agent_log = result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG] + assert agent_log["agent_backend"]["run_id"] == "fake-run-1" + assert agent_log["agent_backend"]["status"] == "succeeded" + assert result.process_data["agent_id"] == "agent-1" + assert result.inputs["agent_backend_request"]["composition"]["layers"][4]["config"]["credentials"] == "[REDACTED]" + + +def test_agent_node_run_maps_failed_agent_backend_run_to_node_result(): + events = list(_node(scenario=FakeAgentBackendScenario.FAILED)._run()) + + assert len(events) == 1 + result = cast(StreamCompletedEvent, events[0]).node_run_result + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "fake failure" + assert result.error_type == "unit_test" + + +def test_agent_node_records_stream_usage_metadata(): + metadata = {"agent_backend": {"run_id": "run-1"}} + + DifyAgentNode._record_stream_metadata( + metadata, + AgentBackendStreamInternalEvent( + run_id="run-1", + source_event_id="1-1", + event_kind="model_response", + data={"usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}}, + ), + ) + + agent_backend = metadata["agent_backend"] + assert agent_backend["last_stream_event_id"] == "1-1" + assert agent_backend["last_stream_event_kind"] == "model_response" + assert agent_backend["usage"] == {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7} diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py new file mode 100644 index 0000000000..1f75e4c19d --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py @@ -0,0 +1,121 @@ +import pytest + +from core.workflow.nodes.agent_v2.binding_resolver import ( + WorkflowAgentBindingError, + WorkflowAgentBindingResolver, +) +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig + + +class FakeSession: + def __init__(self, scalar_results): + self._scalar_results = list(scalar_results) + self.expunge_calls = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def scalar(self, _stmt): + if not self._scalar_results: + return None + return self._scalar_results.pop(0) + + def expunge(self, value): + self.expunge_calls.append(value) + + +def _binding() -> WorkflowAgentNodeBinding: + return WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig(), + ) + + +def _agent(*, status: AgentStatus = AgentStatus.ACTIVE) -> Agent: + return Agent(id="agent-1", tenant_id="tenant-1", name="Agent", status=status) + + +def _snapshot() -> AgentConfigSnapshot: + return AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ) + ), + ) + + +def _resolve() -> dict[str, str]: + return { + "tenant_id": "tenant-1", + "app_id": "app-1", + "workflow_id": "workflow-1", + "node_id": "agent-node", + } + + +def test_binding_resolver_returns_detached_binding_bundle(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession([_binding(), _agent(), _snapshot()]) + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session", + lambda: fake_session, + ) + + bundle = WorkflowAgentBindingResolver().resolve(**_resolve()) + + assert bundle.binding.id == "binding-1" + assert bundle.agent.id == "agent-1" + assert bundle.snapshot.id == "snapshot-1" + assert fake_session.expunge_calls == [bundle.binding, bundle.agent, bundle.snapshot] + + +def test_binding_resolver_raises_when_binding_missing(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session", + lambda: FakeSession([None]), + ) + + with pytest.raises(WorkflowAgentBindingError) as exc_info: + WorkflowAgentBindingResolver().resolve(**_resolve()) + + assert exc_info.value.error_code == "agent_binding_not_found" + + +def test_binding_resolver_raises_when_agent_archived(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session", + lambda: FakeSession([_binding(), _agent(status=AgentStatus.ARCHIVED)]), + ) + + with pytest.raises(WorkflowAgentBindingError) as exc_info: + WorkflowAgentBindingResolver().resolve(**_resolve()) + + assert exc_info.value.error_code == "agent_not_available" + + +def test_binding_resolver_raises_when_snapshot_missing(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session", + lambda: FakeSession([_binding(), _agent(), None]), + ) + + with pytest.raises(WorkflowAgentBindingError) as exc_info: + WorkflowAgentBindingResolver().resolve(**_resolve()) + + assert exc_info.value.error_code == "agent_config_snapshot_not_found" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py new file mode 100644 index 0000000000..8b2feb2ad6 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py @@ -0,0 +1,194 @@ +from agenton.compositor import CompositorSessionSnapshot + +from clients.agent_backend import ( + AgentBackendRunCancelledInternalEvent, + AgentBackendRunFailedInternalEvent, + AgentBackendRunPausedInternalEvent, + AgentBackendRunSucceededInternalEvent, +) +from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter +from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from graphon.file import FileTransferMethod, FileType +from graphon.variables.segments import ArrayFileSegment, FileSegment + + +def test_success_output_adapter_preserves_dict_output(): + result = WorkflowAgentOutputAdapter().build_success_result( + event=AgentBackendRunSucceededInternalEvent( + run_id="run-1", + source_event_id="2-0", + output={"summary": "ok"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + inputs={}, + process_data={}, + metadata={"agent_backend": {"run_id": "run-1"}}, + ) + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs == {"summary": "ok"} + assert result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"]["status"] == "succeeded" + assert result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"]["session_snapshot"] == { + "layer_count": 0, + } + + +def test_failure_output_adapter_maps_paused_to_unsupported_failure(): + result = WorkflowAgentOutputAdapter().build_failure_result( + event=AgentBackendRunPausedInternalEvent( + run_id="run-1", + source_event_id="2-0", + reason="human", + message=None, + session_snapshot=None, + ), + inputs={}, + process_data={}, + metadata={}, + ) + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error_type == "agent_backend_paused_unsupported" + + +def test_failure_output_adapter_preserves_backend_failed_reason(): + result = WorkflowAgentOutputAdapter().build_failure_result( + event=AgentBackendRunFailedInternalEvent( + run_id="run-1", + source_event_id="2-0", + error="bad request", + reason="validation", + ), + inputs={}, + process_data={}, + metadata={}, + ) + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "bad request" + assert result.error_type == "validation" + + +def test_success_output_adapter_normalizes_string_and_scalar_outputs(): + adapter = WorkflowAgentOutputAdapter() + string_result = adapter.build_success_result( + event=AgentBackendRunSucceededInternalEvent( + run_id="run-1", + source_event_id="2-0", + output="hello", + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + inputs={}, + process_data={}, + metadata={}, + ) + scalar_result = adapter.build_success_result( + event=AgentBackendRunSucceededInternalEvent( + run_id="run-2", + source_event_id="2-0", + output=3, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + inputs={}, + process_data={}, + metadata={}, + ) + + assert string_result.outputs == {"text": "hello"} + assert scalar_result.outputs == {"result": 3} + + +def test_success_output_adapter_normalizes_file_output_to_file_segments(): + result = WorkflowAgentOutputAdapter().build_success_result( + event=AgentBackendRunSucceededInternalEvent( + run_id="run-1", + source_event_id="2-0", + output={ + "report": { + "file_id": "upload-file-1", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 12, + }, + "attachments": [ + { + "tool_file_id": "tool-file-1", + "filename": "chart.png", + "mime_type": "image/png", + } + ], + }, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + inputs={}, + process_data={}, + metadata={}, + ) + + report = result.outputs["report"] + assert isinstance(report, FileSegment) + assert report.value.type == FileType.DOCUMENT + assert report.value.transfer_method == FileTransferMethod.LOCAL_FILE + assert report.value.reference == "upload-file-1" + + attachments = result.outputs["attachments"] + assert isinstance(attachments, ArrayFileSegment) + assert attachments.value[0].type == FileType.IMAGE + assert attachments.value[0].transfer_method == FileTransferMethod.TOOL_FILE + assert attachments.value[0].reference == "tool-file-1" + + +def test_success_output_adapter_maps_backend_usage_to_llm_usage_and_metadata(): + result = WorkflowAgentOutputAdapter().build_success_result( + event=AgentBackendRunSucceededInternalEvent( + run_id="run-1", + source_event_id="2-0", + output={"summary": "ok"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + inputs={}, + process_data={}, + metadata={ + "agent_backend": { + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + } + } + }, + ) + + assert result.llm_usage.prompt_tokens == 10 + assert result.llm_usage.completion_tokens == 5 + assert result.llm_usage.total_tokens == 15 + assert result.metadata[WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS] == 15 + + +def test_failure_output_adapter_maps_cancelled_to_failure_code(): + result = WorkflowAgentOutputAdapter().build_failure_result( + event=AgentBackendRunCancelledInternalEvent( + run_id="run-1", + source_event_id="2-0", + reason="user_cancelled", + message=None, + ), + inputs={}, + process_data={}, + metadata={}, + ) + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error_type == "agent_backend_run_cancelled" + + +def test_stream_exhausted_result_is_failed_with_stream_error(): + result = WorkflowAgentOutputAdapter().build_stream_exhausted_result( + inputs={}, + process_data={}, + metadata={"agent_backend": {"run_id": "run-1"}}, + ) + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error_type == "agent_backend_stream_error" + assert result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"]["run_id"] == "run-1" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py new file mode 100644 index 0000000000..d50a61883b --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -0,0 +1,219 @@ +from dataclasses import replace + +import pytest + +from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom +from core.workflow.nodes.agent_v2.runtime_request_builder import ( + WorkflowAgentRuntimeBuildContext, + WorkflowAgentRuntimeRequestBuilder, + WorkflowAgentRuntimeRequestBuildError, +) +from graphon.variables.segments import StringSegment +from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding +from models.agent_config_entities import ( + AgentSoulConfig, + AgentSoulModelConfig, + DeclaredOutputType, + WorkflowNodeJobConfig, +) + + +class FakeCredentialsProvider: + def fetch(self, provider_name: str, model_name: str) -> dict[str, object]: + assert provider_name == "openai" + assert model_name == "gpt-test" + return {"api_key": "secret-key"} + + +class FakeVariablePool: + def get(self, selector): + if list(selector) == ["sys", "query"]: + return StringSegment(value="Summarize the report.") + if list(selector) == ["previous-node", "text"]: + return StringSegment(value="Previous result") + return None + + def get_by_prefix(self, prefix): + return {} + + +def _context() -> WorkflowAgentRuntimeBuildContext: + agent = Agent(id="agent-1", tenant_id="tenant-1", name="Agent") + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + prompt={"system_prompt": "You are careful."}, + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + model_settings={"temperature": 0}, + ), + ), + ) + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig.model_validate( + { + "workflow_prompt": "Use the previous output.", + "previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}], + "declared_outputs": [{"name": "summary", "type": "string"}], + } + ), + ) + return WorkflowAgentRuntimeBuildContext( + dify_context=DifyRunContext( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ), + workflow_id="workflow-1", + workflow_run_id="run-1", + node_id="agent-node", + node_execution_id="node-exec-1", + variable_pool=FakeVariablePool(), + binding=binding, + agent=agent, + snapshot=snapshot, + ) + + +def test_builds_create_run_request_from_agent_soul_and_node_job(): + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context()) + + dumped = result.request.model_dump(mode="json") + assert dumped["execution_context"]["agent_id"] == "agent-1" + assert dumped["execution_context"]["agent_config_version_id"] == "snapshot-1" + assert dumped["execution_context"]["invoke_from"] == "single_step" + assert dumped["idempotency_key"] == "run-1:node-exec-1" + assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful." + assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output." + assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"] + assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string" + assert result.redacted_request["composition"]["layers"][4]["config"]["credentials"] == "[REDACTED]" + + +def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metadata(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + prompt={"system_prompt": "You are careful."}, + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + model_settings={"temperature": 0.2}, + ), + tools={"cli_tools": [{"name": "pytest"}]}, + ), + ) + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + {"name": "report", "type": DeclaredOutputType.FILE}, + {"name": "confidence", "type": DeclaredOutputType.NUMBER, "required": False}, + ], + } + ), + ) + dify_context = context.dify_context.model_copy(update={"invoke_from": InvokeFrom.SERVICE_API}) + context = replace(context, dify_context=dify_context, workflow_run_id=None, snapshot=snapshot, binding=binding) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + assert dumped["execution_context"]["invoke_from"] == "workflow_run" + assert dumped["idempotency_key"] == "node-exec-1" + output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"] + assert output_schema["properties"]["report"]["properties"]["file_id"]["type"] == "string" + assert output_schema["properties"]["confidence"]["type"] == "number" + assert output_schema["required"] == ["report"] + assert dumped["composition"]["layers"][4]["config"]["model_settings"] == {"temperature": 0.2} + assert result.metadata["runtime_support"]["reserved_status"]["tools"] == "reserved_not_executed" + assert result.metadata["runtime_support"]["unsupported_runtime_warnings"][0]["section"] == "agent_soul.tools" + + +def test_requires_agent_soul_model_config(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig(), + ) + context = replace(context, snapshot=snapshot) + + with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="Agent Soul model"): + WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + +def test_missing_previous_node_output_fails_request_build(): + context = _context() + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig.model_validate( + { + "previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}], + } + ), + ) + context = replace(context, binding=binding) + + with pytest.raises(WorkflowAgentRuntimeRequestBuildError) as exc_info: + WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + assert exc_info.value.error_code == "missing_previous_node_output" + + +def test_invalid_previous_node_output_ref_fails_request_build(): + context = _context() + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig.model_validate( + { + "previous_node_output_refs": [{"selector": ["previous-node", 1]}], + } + ), + ) + context = replace(context, binding=binding) + + with pytest.raises(WorkflowAgentRuntimeRequestBuildError) as exc_info: + WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + assert exc_info.value.error_code == "invalid_previous_node_output_ref" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py new file mode 100644 index 0000000000..99d1e91c44 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -0,0 +1,271 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from core.workflow.nodes.agent_v2.validators import ( + WorkflowAgentNodeValidationError, + WorkflowAgentNodeValidator, +) +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig +from models.workflow import Workflow + + +def _workflow(graph: dict) -> Workflow: + return Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + graph=json.dumps(graph), + ) + + +def _binding(node_job: WorkflowNodeJobConfig) -> WorkflowAgentNodeBinding: + return WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node", + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=node_job, + ) + + +def _agent() -> Agent: + return Agent(id="agent-1", tenant_id="tenant-1", name="Agent", status=AgentStatus.ACTIVE) + + +def _snapshot() -> AgentConfigSnapshot: + return AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ) + ), + ) + + +def _graph(edges: list[dict]) -> dict: + return { + "nodes": [ + {"id": "start", "data": {"type": "start"}}, + {"id": "previous-node", "data": {"type": "llm"}}, + {"id": "agent-node", "data": {"type": "agent", "version": "2"}}, + {"id": "later-node", "data": {"type": "llm"}}, + ], + "edges": edges, + } + + +def test_publish_validation_accepts_upstream_previous_output_ref(): + node_job = WorkflowNodeJobConfig.model_validate( + {"previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}]} + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow( + _graph( + [ + {"source": "start", "target": "previous-node"}, + {"source": "previous-node", "target": "agent-node"}, + ] + ) + ), + ) + + +def test_publish_validation_rejects_non_upstream_previous_output_ref(): + node_job = WorkflowNodeJobConfig.model_validate( + {"previous_node_output_refs": [{"node_id": "later-node", "output": "text"}]} + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow( + _graph( + [ + {"source": "start", "target": "agent-node"}, + {"source": "agent-node", "target": "later-node"}, + ] + ) + ), + ) + + +def test_draft_validation_allows_unbound_agent_node(): + session = Mock() + session.scalar.return_value = None + + WorkflowAgentNodeValidator.validate_draft_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_requires_binding(): + session = Mock() + session.scalar.return_value = None + + with pytest.raises(WorkflowAgentNodeValidationError, match="requires a binding"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_duplicate_output_names(): + node_job = WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + {"name": "summary", "type": "string"}, + {"name": "summary", "type": "number"}, + ] + } + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate output name"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_missing_agent_soul_model(): + node_job = WorkflowNodeJobConfig.model_validate({}) + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig(), + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + with pytest.raises(WorkflowAgentNodeValidationError, match="requires Agent Soul model"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_missing_previous_node(): + node_job = WorkflowNodeJobConfig.model_validate( + {"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]} + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="references missing previous node"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_self_previous_output_ref(): + node_job = WorkflowNodeJobConfig.model_validate( + {"previous_node_output_refs": [{"node_id": "agent-node", "output": "text"}]} + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_locked_agent_soul_override_in_metadata(): + node_job = WorkflowNodeJobConfig.model_validate({"metadata": {"agent_soul": {"tools": []}}}) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="cannot override locked Agent Soul fields"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_invalid_human_contact_ref(): + node_job = WorkflowNodeJobConfig.model_validate({"human_contacts": [{"channel": "slack"}]}) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="invalid human contact ref"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_out_of_scope_human_contact_ref(): + node_job = WorkflowNodeJobConfig.model_validate( + {"human_contacts": [{"contact_id": "human-1", "tenant_id": "other-tenant", "channel": "slack"}]} + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + + with pytest.raises(WorkflowAgentNodeValidationError, match="out-of-scope human contact"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_accepts_tenant_scoped_file_ref(): + node_job = WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + { + "name": "report", + "type": "file", + "checks": [{"type": "benchmark", "benchmark_file_ref": {"upload_file_id": "file-1"}}], + } + ] + } + ) + session = Mock() + session.scalar.side_effect = [ + _binding(node_job), + _agent(), + _snapshot(), + SimpleNamespace(id="file-1", tenant_id="tenant-1"), + ] + + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_missing_file_ref(): + node_job = WorkflowNodeJobConfig.model_validate({"metadata": {"file_refs": [{"upload_file_id": "missing-file"}]}}) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot(), None] + + with pytest.raises(WorkflowAgentNodeValidationError, match="missing or out-of-scope metadata file ref"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) diff --git a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py index 2dd3953d9a..c7ff7e5a34 100644 --- a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py +++ b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py @@ -24,6 +24,7 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): from core.workflow import workflow_entry from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from core.workflow.node_factory import DifyNodeFactory, NODE_TYPE_CLASSES_MAPPING + from core.workflow.nodes.agent_v2 import DifyAgentNode from graphon.enums import BuiltinNodeTypes from services import workflow_service from services.rag_pipeline import rag_pipeline @@ -40,6 +41,11 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): assert node_type in NODE_TYPE_CLASSES_MAPPING, node_type resolved = DifyNodeFactory._resolve_node_class(node_type=node_type, node_version="1") assert resolved.__module__.startswith("core.workflow.nodes."), resolved.__module__ + + assert DifyNodeFactory._resolve_node_class( + node_type=BuiltinNodeTypes.AGENT, + node_version="2", + ) is DifyAgentNode """ ) completed = subprocess.run( diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py index c6f57c7e59..95085eaf67 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py @@ -176,6 +176,48 @@ class TestStreamsBroadcastChannel: assert topic.as_producer() is topic assert topic.as_subscriber() is topic + def test_join_timeout_ms_propagates_from_channel_to_subscription(self, fake_redis: FakeStreamsRedis): + channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=150) + topic = channel.topic("join-timeout-prop") + + assert topic._join_timeout_ms == 150 + + sub = topic.subscribe() + try: + assert sub._join_timeout_ms == 150 + finally: + sub.close() + + def test_join_timeout_ms_defaults_to_2000(self, fake_redis: FakeStreamsRedis): + channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60) + topic = channel.topic("join-timeout-default") + + assert topic._join_timeout_ms == 2000 + + def test_small_join_timeout_makes_close_return_promptly(self, fake_redis: FakeStreamsRedis): + """close() should respect the configured join timeout. + + Regression test for SSE close tail latency: when an idle listener is + blocked on its poll cycle, close() with a small join_timeout_ms must + not wait for the full poll window. The orphaned daemon listener + cleans itself up later. + """ + channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=50) + topic = channel.topic("join-timeout-prompt-close") + sub = topic.subscribe() + + # Drive listener startup so the thread is actually blocked in xread. + assert sub.receive(timeout=0.05) is None + time.sleep(0.05) + + started = time.monotonic() + sub.close() + elapsed = time.monotonic() - started + + # 50ms timeout + scheduling slack; pick a ceiling well under the + # default poll window (1000ms) to make the regression meaningful. + assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return" + def test_publish_logs_warning_when_expire_fails(self, caplog: pytest.LogCaptureFixture): channel = StreamsBroadcastChannel(FailExpireRedis(), retention_seconds=60) topic = channel.topic("expire-warning") @@ -342,10 +384,17 @@ class TestStreamsSubscription: assert next(iter(subscription)) == b"event" - def test_close_logs_warning_when_listener_does_not_stop_in_time( + def test_close_logs_debug_when_listener_does_not_stop_in_time( self, caplog: pytest.LogCaptureFixture, ): + """When a low join_timeout elapses with the listener still alive, + close() should log at DEBUG (not WARNING) - with a deliberately small + timeout this is expected, not anomalous; the orphaned daemon thread + cleans itself up on the next poll boundary. + """ + import logging + blocking_redis = BlockingRedis() subscription = _StreamsSubscription(blocking_redis, "stream:slow-close") @@ -363,8 +412,10 @@ class TestStreamsSubscription: listener.is_alive = lambda: True # type: ignore[method-assign] try: - subscription.close() - assert "did not stop within timeout" in caplog.text + with caplog.at_level(logging.DEBUG, logger="libs.broadcast_channel.redis.streams_channel"): + subscription.close() + assert "did not stop within" in caplog.text + assert "daemon thread will exit on its own" in caplog.text finally: listener.join = original_join # type: ignore[method-assign] listener.is_alive = original_is_alive # type: ignore[method-assign] diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index 4cbc9cad8c..af7ae36644 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -1,6 +1,6 @@ import pytest -from models.agent_config_entities import AgentKnowledgeQueryMode, DeclaredOutputType +from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import AgentSoulLockedError, PlaintextSecretNotAllowedError @@ -88,6 +88,22 @@ def test_knowledge_query_mode_uses_stable_backend_enums(): assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY +def test_agent_soul_model_config_is_first_class_without_credentials(): + config = AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + credential_ref={"type": "provider", "id": "credential-1"}, + model_settings={"temperature": 0}, + ) + ) + + dumped = config.model_dump(mode="json") + assert dumped["model"]["plugin_id"] == "langgenius/openai" + assert dumped["model"]["credential_ref"] == {"type": "provider", "id": "credential-1", "provider": None} + + def test_declared_outputs_support_file_check_and_failure_strategy(): node_job = WorkflowNodeJobConfig.model_validate( { diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index 51f8b3ef5b..005dcec886 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from unittest.mock import MagicMock, patch @@ -1056,6 +1057,117 @@ class TestMessageServiceSuggestedQuestions: ) mock_model_manager.return_value.get_model_instance.assert_not_called() + @patch("services.message_service.db") + @patch("services.message_service.ModelManager.for_tenant") + @patch("services.message_service.TokenBufferMemory") + @patch("services.message_service.LLMGenerator") + @patch("services.message_service.TraceQueueManager") + @patch.object(MessageService, "get_message") + @patch("services.message_service.ConversationService") + def test_get_suggested_questions_chat_app_uses_compatible_override_model_config( + self, + mock_conversation_service, + mock_get_message, + mock_trace_manager, + mock_llm_gen, + mock_memory, + mock_model_manager, + mock_db, + factory, + ): + """Test legacy override configs are normalized before suggested questions reads them.""" + app = factory.create_app_mock(mode=AppMode.CHAT) + app.tenant_id = "tenant-123" + user = factory.create_end_user_mock() + message = factory.create_message_mock() + mock_get_message.return_value = message + + conversation = MagicMock() + conversation.override_model_configs = json.dumps( + { + "speech_to_text": {"enabled": False}, + "text_to_speech": {"enabled": False}, + "retriever_resource": {"enabled": False}, + "model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"}, + "user_input_form": [], + "dataset_query_variable": "", + "pre_prompt": "", + "agent_mode": { + "enabled": False, + "max_iteration": 5, + "strategy": "function_call", + "tools": [], + }, + "prompt_type": "simple", + "chat_prompt_config": {}, + "completion_prompt_config": {}, + "dataset_configs": {"retrieval_model": "single", "datasets": {"datasets": []}}, + "file_upload": { + "image": { + "detail": "high", + "enabled": False, + "number_limits": 3, + "transfer_methods": ["remote_url", "local_file"], + } + }, + "suggested_questions_after_answer": { + "enabled": True, + "prompt": "legacy prompt", + }, + } + ) + conversation.model_config = { + "opening_statement": None, + "suggested_questions": [], + "suggested_questions_after_answer": { + "enabled": True, + "prompt": "legacy prompt", + }, + "speech_to_text": {"enabled": False}, + "text_to_speech": {"enabled": False}, + "retriever_resource": {"enabled": False}, + "annotation_reply": {"enabled": False}, + "more_like_this": {"enabled": False}, + "sensitive_word_avoidance": {"enabled": False, "type": "", "config": {}}, + "external_data_tools": [], + "model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"}, + "user_input_form": [], + "dataset_query_variable": "", + "pre_prompt": "", + "agent_mode": {"enabled": False, "strategy": "function_call", "tools": [], "prompt": None}, + "prompt_type": "simple", + "chat_prompt_config": {}, + "completion_prompt_config": {}, + "dataset_configs": {"retrieval_model": "single", "datasets": {"datasets": []}}, + "file_upload": { + "image": { + "detail": "high", + "enabled": False, + "number_limits": 3, + "transfer_methods": ["remote_url", "local_file"], + } + }, + "model_id": None, + "provider": None, + } + mock_conversation_service.get_conversation.return_value = conversation + + mock_memory.return_value.get_history_prompt_text.return_value = "histories" + mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] + + result = MessageService.get_suggested_questions_after_answer( + app_model=app, user=user, message_id="msg-123", invoke_from=MagicMock() + ) + + assert result == ["Q1?"] + mock_db.session.scalar.assert_not_called() + mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with( + tenant_id="tenant-123", + histories="histories", + instruction_prompt="legacy prompt", + model_config=None, + ) + # Test 30: get_suggested_questions_after_answer - Disabled Error @patch("services.message_service.WorkflowService") @patch("services.message_service.AdvancedChatAppConfigManager") diff --git a/api/uv.lock b/api/uv.lock index b6231698d2..5e8792207e 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -51,8 +51,8 @@ members = [ "dify-vdb-weaviate", ] overrides = [ - { name = "litellm", specifier = ">=1.83.10" }, - { name = "pyarrow", specifier = ">=18.0.0" }, + { name = "litellm", specifier = ">=1.83.10,<2.0.0" }, + { name = "pyarrow", specifier = ">=23.0.1,<24.0.0" }, ] [[package]] @@ -1291,17 +1291,17 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" }, - { name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" }, + { name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" }, + { name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, { name = "pydantic", specifier = ">=2.12.5,<2.13" }, - { name = "pydantic-ai-slim", specifier = ">=1.85.1" }, - { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" }, - { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" }, - { name = "redis", marker = "extra == 'server'", specifier = ">=5" }, - { name = "typing-extensions", specifier = ">=4.12.2" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" }, + { name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, + { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, + { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, + { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, ] provides-extras = ["server"] @@ -1609,46 +1609,46 @@ vdb-xinference = [ [package.metadata] requires-dist = [ - { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, + { name = "aliyun-log-python-sdk", specifier = "==0.9.44" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, - { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.43.10" }, - { name = "celery", specifier = ">=5.6.3" }, - { name = "croniter", specifier = ">=6.2.2" }, + { name = "bleach", specifier = ">=6.3.0,<7.0.0" }, + { name = "boto3", specifier = ">=1.43.10,<2.0.0" }, + { name = "celery", specifier = ">=5.6.3,<6.0.0" }, + { name = "croniter", specifier = ">=6.2.2,<7.0.0" }, { name = "dify-agent", directory = "../dify-agent" }, - { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, + { name = "fastopenapi", extras = ["flask"], specifier = "==0.7.0" }, { name = "flask", specifier = ">=3.1.3,<4.0.0" }, { name = "flask-compress", specifier = ">=1.24,<2.0.0" }, - { name = "flask-cors", specifier = ">=6.0.2" }, - { name = "flask-login", specifier = ">=0.6.3,<1.0.0" }, + { name = "flask-cors", specifier = ">=6.0.2,<7.0.0" }, + { name = "flask-login", specifier = "==0.6.3" }, { name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" }, { name = "flask-orjson", specifier = ">=2.0.0,<3.0.0" }, { name = "flask-restx", specifier = ">=1.3.2,<2.0.0" }, - { name = "gevent", specifier = ">=26.4.0" }, - { name = "gevent-websocket", specifier = ">=0.10.1" }, - { name = "gmpy2", specifier = ">=2.3.0" }, - { name = "google-api-python-client", specifier = ">=2.196.0" }, + { name = "gevent", specifier = ">=26.4.0,<26.5.0" }, + { name = "gevent-websocket", specifier = "==0.10.1" }, + { name = "gmpy2", specifier = ">=2.3.0,<3.0.0" }, + { name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, - { name = "graphon", specifier = "~=0.4.0" }, - { name = "gunicorn", specifier = ">=26.0.0" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, - { name = "httpx-sse", specifier = "~=0.4.0" }, - { name = "json-repair", specifier = "~=0.59.4" }, - { name = "opentelemetry-distro", specifier = ">=0.62b1,<1.0.0" }, - { name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" }, - { name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" }, - { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.62b0,<1.0.0" }, - { name = "opentelemetry-instrumentation-redis", specifier = ">=0.62b0,<1.0.0" }, - { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.62b0,<1.0.0" }, + { name = "graphon", specifier = "==0.4.0" }, + { name = "gunicorn", specifier = ">=26.0.0,<27.0.0" }, + { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, + { name = "httpx-sse", specifier = "==0.4.3" }, + { name = "json-repair", specifier = "==0.59.4" }, + { name = "opentelemetry-distro", specifier = "==0.62b1" }, + { name = "opentelemetry-instrumentation-celery", specifier = "==0.62b1" }, + { name = "opentelemetry-instrumentation-flask", specifier = "==0.62b1" }, + { name = "opentelemetry-instrumentation-httpx", specifier = "==0.62b1" }, + { name = "opentelemetry-instrumentation-redis", specifier = "==0.62b1" }, + { name = "opentelemetry-instrumentation-sqlalchemy", specifier = "==0.62b1" }, { name = "opentelemetry-propagator-b3", specifier = ">=1.41.1,<2.0.0" }, - { name = "psycogreen", specifier = ">=1.0.2" }, - { name = "psycopg2-binary", specifier = ">=2.9.12" }, - { name = "python-socketio", specifier = ">=5.13.0" }, - { name = "readabilipy", specifier = ">=0.3.0,<1.0.0" }, - { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" }, + { name = "psycogreen", specifier = ">=1.0.2,<2.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.12,<3.0.0" }, + { name = "python-socketio", specifier = ">=5.13.0,<6.0.0" }, + { name = "readabilipy", specifier = "==0.3.0" }, + { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" }, { name = "resend", specifier = ">=2.27.0,<3.0.0" }, - { name = "sendgrid", specifier = ">=6.12.5" }, - { name = "sseclient-py", specifier = ">=1.8.0" }, + { name = "sendgrid", specifier = ">=6.12.5,<7.0.0" }, + { name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" }, ] [package.metadata.requires-dev] @@ -1716,19 +1716,19 @@ dev = [ { name = "xinference-client", specifier = ">=2.7.0" }, ] storage = [ - { name = "azure-storage-blob", specifier = ">=12.29.0" }, - { name = "bce-python-sdk", specifier = ">=0.9.71" }, - { name = "cos-python-sdk-v5", specifier = ">=1.9.43" }, - { name = "esdk-obs-python", specifier = ">=3.22.2" }, - { name = "google-cloud-storage", specifier = ">=3.10.1" }, - { name = "opendal", specifier = ">=0.46.0" }, - { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.30.0" }, - { name = "tos", specifier = ">=2.9.0" }, + { name = "azure-storage-blob", specifier = ">=12.29.0,<13.0.0" }, + { name = "bce-python-sdk", specifier = "==0.9.71" }, + { name = "cos-python-sdk-v5", specifier = ">=1.9.43,<2.0.0" }, + { name = "esdk-obs-python", specifier = ">=3.22.2,<4.0.0" }, + { name = "google-cloud-storage", specifier = ">=3.10.1,<4.0.0" }, + { name = "opendal", specifier = "==0.46.0" }, + { name = "oss2", specifier = ">=2.19.1,<3.0.0" }, + { name = "supabase", specifier = ">=2.30.0,<3.0.0" }, + { name = "tos", specifier = ">=2.9.0,<3.0.0" }, ] tools = [ - { name = "cloudscraper", specifier = ">=1.2.71" }, - { name = "nltk", specifier = ">=3.9.1" }, + { name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" }, + { name = "nltk", specifier = ">=3.9.1,<4.0.0" }, ] trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }] trace-all = [ @@ -1810,7 +1810,7 @@ vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstas vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }] vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }] vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] -vdb-xinference = [{ name = "xinference-client", specifier = ">=2.7.0" }] +vdb-xinference = [{ name = "xinference-client", specifier = ">=2.7.0,<3.0.0" }] [[package]] name = "dify-trace-aliyun" @@ -1840,7 +1840,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "arize-phoenix-otel", specifier = "~=0.15.0" }] +requires-dist = [{ name = "arize-phoenix-otel", specifier = "==0.15.0" }] [[package]] name = "dify-trace-langfuse" @@ -1862,7 +1862,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "langsmith", specifier = ">=0.8.0" }] +requires-dist = [{ name = "langsmith", specifier = "==0.8.5" }] [[package]] name = "dify-trace-mlflow" @@ -1873,7 +1873,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mlflow-skinny", specifier = ">=3.11.1" }] +requires-dist = [{ name = "mlflow-skinny", specifier = ">=3.11.1,<4.0.0" }] [[package]] name = "dify-trace-opik" @@ -1914,7 +1914,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "weave", specifier = ">=0.52.36" }] +requires-dist = [{ name = "weave", specifier = "==0.52.36" }] [[package]] name = "dify-vdb-alibabacloud-mysql" @@ -1925,7 +1925,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mysql-connector-python", specifier = ">=9.3.0" }] +requires-dist = [{ name = "mysql-connector-python", specifier = ">=9.3.0,<10.0.0" }] [[package]] name = "dify-vdb-analyticdb" @@ -1940,8 +1940,8 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "alibabacloud-gpdb20160503", specifier = "~=5.2.0" }, - { name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" }, - { name = "clickhouse-connect", specifier = "~=0.15.0" }, + { name = "alibabacloud-tea-openapi", specifier = "==0.4.4" }, + { name = "clickhouse-connect", specifier = "==0.15.1" }, ] [[package]] @@ -1975,7 +1975,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "clickzetta-connector-python", specifier = ">=0.8.102" }] +requires-dist = [{ name = "clickzetta-connector-python", specifier = "==0.8.104" }] [[package]] name = "dify-vdb-couchbase" @@ -2008,7 +2008,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "holo-search-sdk", specifier = ">=0.4.2" }] +requires-dist = [{ name = "holo-search-sdk", specifier = "==0.4.2" }] [[package]] name = "dify-vdb-huawei-cloud" @@ -2030,7 +2030,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "intersystems-irispython", specifier = ">=5.1.0" }] +requires-dist = [{ name = "intersystems-irispython", specifier = ">=5.1.0,<6.0.0" }] [[package]] name = "dify-vdb-lindorm" @@ -2044,7 +2044,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "opensearch-py", specifier = "==3.1.0" }, - { name = "tenacity", specifier = ">=8.0.0" }, + { name = "tenacity", specifier = ">=8.0.0,<9.0.0" }, ] [[package]] @@ -2056,7 +2056,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mo-vector", specifier = "~=0.1.13" }] +requires-dist = [{ name = "mo-vector", specifier = "==0.1.13" }] [[package]] name = "dify-vdb-milvus" @@ -2078,7 +2078,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "clickhouse-connect", specifier = "~=0.15.0" }] +requires-dist = [{ name = "clickhouse-connect", specifier = "==0.15.1" }] [[package]] name = "dify-vdb-oceanbase" @@ -2091,8 +2091,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "mysql-connector-python", specifier = ">=9.3.0" }, - { name = "pyobvector", specifier = "~=0.2.17" }, + { name = "mysql-connector-python", specifier = ">=9.3.0,<10.0.0" }, + { name = "pyobvector", specifier = "==0.2.25" }, ] [[package]] @@ -2131,7 +2131,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.2" }] +requires-dist = [{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "==0.2.2" }] [[package]] name = "dify-vdb-pgvector" @@ -2224,7 +2224,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "pyobvector", specifier = "~=0.2.17" }] +requires-dist = [{ name = "pyobvector", specifier = "==0.2.25" }] [[package]] name = "dify-vdb-vikingdb" @@ -6443,11 +6443,11 @@ wheels = [ [[package]] name = "tenacity" -version = "9.1.2" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, ] [[package]] diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index d9b2796570..7975b042d4 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -3,23 +3,23 @@ name = "dify-agent" version = "0.1.0" description = "Add your description here" readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.12,<4.0" dependencies = [ - "httpx>=0.28.1", + "httpx==0.28.1", "pydantic>=2.12.5,<2.13", - "pydantic-ai-slim>=1.85.1", - "typing-extensions>=4.12.2", + "pydantic-ai-slim>=1.85.1,<2.0.0", + "typing-extensions>=4.12.2,<5.0.0", ] [project.optional-dependencies] server = [ - "fastapi>=0.136.0", - "graphon~=0.2.2", - "jsonschema>=4.23.0", - "pydantic-ai-slim[anthropic,google,openai]>=1.85.1", - "pydantic-settings>=2.12.0", - "redis>=5", - "uvicorn[standard]>=0.38.0", + "fastapi==0.136.0", + "graphon==0.2.2", + "jsonschema>=4.23.0,<5.0.0", + "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "redis>=7.4.0,<8.0.0", + "uvicorn[standard]==0.46.0", ] [tool.setuptools.packages.find] diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index d3b4b09ba0..f18d4e3e4a 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", @@ -610,17 +610,17 @@ docs = [ [package.metadata] requires-dist = [ - { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" }, - { name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" }, + { name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" }, + { name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, { name = "pydantic", specifier = ">=2.12.5,<2.13" }, - { name = "pydantic-ai-slim", specifier = ">=1.85.1" }, - { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" }, - { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" }, - { name = "redis", marker = "extra == 'server'", specifier = ">=5" }, - { name = "typing-extensions", specifier = ">=4.12.2" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" }, + { name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, + { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, + { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, + { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, ] provides-extras = ["server"] diff --git a/docker/.env.example b/docker/.env.example index c708a40c15..c723d25d9b 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -118,6 +118,7 @@ CELERY_TASK_ANNOTATIONS=null EVENT_BUS_REDIS_URL= EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub EVENT_BUS_REDIS_USE_CLUSTERS=false +EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000 # Web and app limits WEB_API_CORS_ALLOW_ORIGINS=* diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 574e441d0b..9f684a896b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -784,11 +784,6 @@ "count": 10 } }, - "web/app/components/base/chip/index.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 @@ -1579,26 +1574,6 @@ "count": 1 } }, - "web/app/components/base/radio-card/index.stories.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/radio/component/group/index.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/base/radio/context/index.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/radio/index.stories.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/search-input/index.stories.tsx": { "no-console": { "count": 3 @@ -1607,11 +1582,6 @@ "count": 1 } }, - "web/app/components/base/sort/index.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/svg-gallery/index.tsx": { "node/prefer-global/buffer": { "count": 1 @@ -2120,11 +2090,6 @@ "count": 2 } }, - "web/app/components/explore/item-operation/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/explore/try-app/tab.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2596,11 +2561,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx": { - "react/set-state-in-effect": { - "count": 2 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -2778,11 +2738,6 @@ "count": 1 } }, - "web/app/components/share/text-generation/menu-dropdown.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/share/text-generation/no-data/index.tsx": { "ts/no-empty-object-type": { "count": 1 @@ -2950,11 +2905,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/market-place-plugin/item.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -4051,11 +4001,6 @@ "count": 1 } }, - "web/app/components/workflow/operator/zoom-in-out.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/workflow/panel/__tests__/index.spec.tsx": { "react/static-components": { "count": 2 @@ -4378,11 +4323,6 @@ "count": 1 } }, - "web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/education-apply/hooks.ts": { "react/set-state-in-effect": { "count": 5 diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts index 10a784b9dd..8a4540f933 100644 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -34,6 +34,7 @@ export type AgentSoulConfig = { misc_legacy?: { [key: string]: unknown } + model?: AgentSoulModelConfig prompt?: AgentSoulPromptConfig sandbox?: AgentSoulSandboxConfig schema_version?: number @@ -86,6 +87,16 @@ export type AgentSoulMemoryConfig = { scope?: string | null } +export type AgentSoulModelConfig = { + credential_ref?: AgentSoulModelCredentialRef + model: string + model_provider: string + model_settings?: { + [key: string]: unknown + } + plugin_id: string +} + export type AgentSoulPromptConfig = { system_prompt?: string } @@ -117,6 +128,12 @@ export type AgentSoulToolsConfig = { export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' +export type AgentSoulModelCredentialRef = { + id?: string | null + provider?: string | null + type: string +} + export type GetAgentsData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts index dd9cabdffd..f84b5fc411 100644 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -100,6 +100,30 @@ export const zAgentSoulKnowledgeConfig = z.object({ query_mode: zAgentKnowledgeQueryMode.optional(), }) +/** + * AgentSoulModelCredentialRef + * + * Reference to model credentials resolved only at runtime. + */ +export const zAgentSoulModelCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulModelConfig + * + * Stable model selection for Agent runtime without storing secret values. + */ +export const zAgentSoulModelConfig = z.object({ + credential_ref: zAgentSoulModelCredentialRef.optional(), + model: z.string().min(1).max(255), + model_provider: z.string().min(1).max(255), + model_settings: z.record(z.string(), z.unknown()).optional(), + plugin_id: z.string().min(1).max(255), +}) + /** * AgentSoulConfig */ @@ -111,6 +135,7 @@ export const zAgentSoulConfig = z.object({ knowledge: zAgentSoulKnowledgeConfig.optional(), memory: zAgentSoulMemoryConfig.optional(), misc_legacy: z.record(z.string(), z.unknown()).optional(), + model: zAgentSoulModelConfig.optional(), prompt: zAgentSoulPromptConfig.optional(), sandbox: zAgentSoulSandboxConfig.optional(), schema_version: z.int().optional().default(1), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 55bf4e4722..5a529ea49d 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -973,6 +973,7 @@ export type AgentSoulConfig = { misc_legacy?: { [key: string]: unknown } + model?: AgentSoulModelConfig prompt?: AgentSoulPromptConfig sandbox?: AgentSoulSandboxConfig schema_version?: number @@ -1395,6 +1396,16 @@ export type AgentSoulMemoryConfig = { scope?: string | null } +export type AgentSoulModelConfig = { + credential_ref?: AgentSoulModelCredentialRef + model: string + model_provider: string + model_settings?: { + [key: string]: unknown + } + plugin_id: string +} + export type AgentSoulPromptConfig = { system_prompt?: string } @@ -1507,6 +1518,12 @@ export type WorkflowRunForArchivedLogResponse = { export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' +export type AgentSoulModelCredentialRef = { + id?: string | null + provider?: string | null + type: string +} + export type DeclaredOutputCheckConfig = { benchmark_file_ref?: { [key: string]: unknown diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index cd7175f388..5c5b0fa213 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1747,6 +1747,30 @@ export const zAgentSoulKnowledgeConfig = z.object({ query_mode: zAgentKnowledgeQueryMode.optional(), }) +/** + * AgentSoulModelCredentialRef + * + * Reference to model credentials resolved only at runtime. + */ +export const zAgentSoulModelCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulModelConfig + * + * Stable model selection for Agent runtime without storing secret values. + */ +export const zAgentSoulModelConfig = z.object({ + credential_ref: zAgentSoulModelCredentialRef.optional(), + model: z.string().min(1).max(255), + model_provider: z.string().min(1).max(255), + model_settings: z.record(z.string(), z.unknown()).optional(), + plugin_id: z.string().min(1).max(255), +}) + /** * AgentSoulConfig */ @@ -1758,6 +1782,7 @@ export const zAgentSoulConfig = z.object({ knowledge: zAgentSoulKnowledgeConfig.optional(), memory: zAgentSoulMemoryConfig.optional(), misc_legacy: z.record(z.string(), z.unknown()).optional(), + model: zAgentSoulModelConfig.optional(), prompt: zAgentSoulPromptConfig.optional(), sandbox: zAgentSoulSandboxConfig.optional(), schema_version: z.int().optional().default(1), diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index d58fb954e0..a7dbac0f1c 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -73,6 +73,14 @@ "types": "./src/number-field/index.tsx", "import": "./src/number-field/index.tsx" }, + "./radio": { + "types": "./src/radio/index.tsx", + "import": "./src/radio/index.tsx" + }, + "./radio-group": { + "types": "./src/radio-group/index.tsx", + "import": "./src/radio-group/index.tsx" + }, "./popover": { "types": "./src/popover/index.tsx", "import": "./src/popover/index.tsx" diff --git a/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx b/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx new file mode 100644 index 0000000000..423ce95749 --- /dev/null +++ b/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import { render } from 'vitest-browser-react' +import { FieldItem, FieldLabel, FieldRoot } from '../../field' +import { FieldsetLegend, FieldsetRoot } from '../../fieldset' +import { Radio } from '../../radio' +import { RadioGroup } from '../index' + +const clickElement = (element: HTMLElement | SVGElement) => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) +} + +describe('RadioGroup', () => { + it('should manage a controlled single selection', async () => { + function StorageDemo() { + const [value, setValue] = useState('ssd') + + return ( + + }> + Storage type + + + + SSD + + + + + + HDD + + + + + ) + } + + const screen = await render() + + await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'true') + + clickElement(screen.getByRole('radio', { name: 'HDD' }).element()) + + await vi.waitFor(async () => { + await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'false') + await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true') + }) + }) + + it('should compose with Dify UI Field and Fieldset without losing labels', async () => { + const onValueChange = vi.fn() + const screen = await render( + + }> + Storage type + + + + SSD + + + + + + HDD + + + + , + ) + + await expect.element(screen.getByRole('radiogroup', { name: 'Storage type' })).toBeInTheDocument() + + const hdd = screen.getByRole('radio', { name: 'HDD' }) + await expect.element(hdd).toHaveAttribute('aria-checked', 'false') + + clickElement(hdd.element()) + + expect(onValueChange).toHaveBeenCalledTimes(1) + expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd') + }) +}) diff --git a/packages/dify-ui/src/radio-group/index.stories.tsx b/packages/dify-ui/src/radio-group/index.stories.tsx new file mode 100644 index 0000000000..c2c2451806 --- /dev/null +++ b/packages/dify-ui/src/radio-group/index.stories.tsx @@ -0,0 +1,217 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { RadioGroup } from '.' +import { + FieldDescription, + FieldItem, + FieldLabel, + FieldRoot, +} from '../field' +import { FieldsetLegend, FieldsetRoot } from '../fieldset' +import { Radio, RadioControl, RadioRoot } from '../radio' + +const meta = { + title: 'Base/Form/RadioGroup', + component: RadioGroup, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, make the card itself a RadioRoot with variant="unstyled" and render RadioControl inside it.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +function StandardFormRowsDemo() { + const [value, setValue] = useState('vector') + + return ( + + + )} + > + Retrieval index + {[ + { value: 'vector', label: 'Vector storage' }, + { value: 'keyword', label: 'Keyword index' }, + { value: 'hybrid', label: 'Hybrid retrieval' }, + ].map(option => ( + + + + {option.label} + + + ))} + + + ) +} + +export const StandardFormRows: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Default form composition. Most product code should use this shape: RadioGroup owns value, FieldsetLegend names the group, and FieldLabel makes each row clickable.', + }, + }, + }, +} + +function BooleanInlineDemo() { + const [value, setValue] = useState(true) + + return ( + + value={value} onValueChange={setValue} className="gap-3" /> + )} + > + Streaming output +
+ + + + True + + + + + + False + + +
+
+
+ ) +} + +export const BooleanInline: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Compact boolean radio fields. This is the pattern used by model parameters and dynamic boolean schema fields.', + }, + }, + }, +} + +function OptionCardsDemo() { + const [value, setValue] = useState('default') + + return ( + + + )} + > + Prompt mode + {[ + { + value: 'default', + title: 'Default prompt', + description: 'Use the built-in prompt for consistent output.', + }, + { + value: 'custom', + title: 'Custom prompt', + description: 'Write a prompt for this app and keep full control.', + }, + ].map(option => ( + } + className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg" + > +
+
+
+ {option.title} +
+
+ {option.description} +
+
+
+
+ ))} +
+
+ ) +} + +export const OptionCards: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Use RadioRoot with variant="unstyled" when the entire option card is the radio. RadioControl renders the visual dot inside the card.', + }, + }, + }, +} + +function DynamicFormFieldDemo() { + const options = [ + { value: 'automatic', label: 'Automatic' }, + { value: 'high_quality', label: 'High quality' }, + { value: 'economy', label: 'Economy' }, + ] + const [selected, setSelected] = useState('automatic') + + return ( + + + This mirrors Dify dynamic form fields where radio options are controlled by schema and persisted as a single value. + + + )} + > + + Generation mode + + {options.map(option => ( + + + + {option.label} + + + ))} + + + ) +} + +export const DynamicFormField: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Matches Dify form composition: Field and Fieldset provide group labeling while RadioGroup owns controlled single-selection state.', + }, + }, + }, +} diff --git a/packages/dify-ui/src/radio-group/index.tsx b/packages/dify-ui/src/radio-group/index.tsx new file mode 100644 index 0000000000..2e1dea0e0a --- /dev/null +++ b/packages/dify-ui/src/radio-group/index.tsx @@ -0,0 +1,23 @@ +'use client' + +import type { RadioGroup as BaseRadioGroupNS } from '@base-ui/react/radio-group' +import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group' +import { cn } from '../cn' + +export type RadioGroupProps + = Omit, 'className'> + & { + className?: string + } + +export function RadioGroup({ + className, + ...props +}: RadioGroupProps) { + return ( + + ) +} diff --git a/packages/dify-ui/src/radio/__tests__/index.spec.tsx b/packages/dify-ui/src/radio/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ba02f8a0c4 --- /dev/null +++ b/packages/dify-ui/src/radio/__tests__/index.spec.tsx @@ -0,0 +1,178 @@ +import type { ComponentProps, ReactNode } from 'react' +import { render } from 'vitest-browser-react' +import { FieldItem, FieldLabel, FieldRoot } from '../../field' +import { FieldsetLegend, FieldsetRoot } from '../../fieldset' +import { RadioGroup } from '../../radio-group' +import { + Radio, + RadioControl, + RadioIndicator, + RadioRoot, + RadioSkeleton, +} from '../index' + +const clickElement = (element: HTMLElement | SVGElement) => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) +} + +type TestRadioGroupProps = ComponentProps & { + children: ReactNode + label: string + name?: string +} + +function TestRadioGroup({ + children, + label, + name = 'radioField', + ...props +}: TestRadioGroupProps) { + return ( + + }> + {label} + {children} + + + ) +} + +type TestRadioOptionProps = ComponentProps & { + children: ReactNode +} + +function TestRadioOption({ + children, + ...props +}: TestRadioOptionProps) { + return ( + + + + {children} + + + ) +} + +describe('Radio', () => { + it('should render unchecked and checked radios with Base UI semantics', async () => { + const screen = await render( + + SSD + HDD + , + ) + + const ssd = screen.getByRole('radio', { name: 'SSD' }) + const hdd = screen.getByRole('radio', { name: 'HDD' }) + + await expect.element(ssd).toHaveAttribute('aria-checked', 'true') + await expect.element(ssd).toHaveAttribute('data-checked', '') + await expect.element(ssd).toHaveClass('data-checked:border-components-radio-border-checked') + await expect.element(hdd).toHaveAttribute('aria-checked', 'false') + await expect.element(hdd).toHaveAttribute('data-unchecked', '') + }) + + it('should call onValueChange and update uncontrolled state when selected', async () => { + const onValueChange = vi.fn() + const screen = await render( + + SSD + HDD + , + ) + + clickElement(screen.getByRole('radio', { name: 'HDD' }).element()) + + expect(onValueChange).toHaveBeenCalledTimes(1) + expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd') + await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true') + }) + + it('should ignore interaction when disabled', async () => { + const onValueChange = vi.fn() + const screen = await render( + + SSD + HDD + , + ) + + const hdd = screen.getByRole('radio', { name: 'HDD' }) + await expect.element(hdd).toHaveAttribute('data-disabled', '') + await expect.element(hdd).toHaveClass('data-disabled:cursor-not-allowed') + + clickElement(hdd.element()) + + expect(onValueChange).not.toHaveBeenCalled() + await expect.element(hdd).toHaveAttribute('aria-checked', 'false') + }) + + it('should submit the selected group value through the hidden input', async () => { + const screen = await render( +
+ + SSD + HDD + +
, + ) + const form = screen.container.querySelector('form') + expect(form).not.toBeNull() + if (!form) + return + + const data = new FormData(form) + + expect(data.get('storageType')).toBe('ssd') + }) + + it('should support custom compound composition with RadioRoot and RadioIndicator', async () => { + const screen = await render( + + + + + + + Custom + + + , + ) + + await expect.element(screen.getByRole('radio', { name: 'Custom' })).toHaveClass('custom-root') + expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument() + }) + + it('should support unstyled roots with a visual RadioControl for option cards', async () => { + const screen = await render( + + } + > + Card option + + + , + ) + + await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveClass('custom-card') + expect(screen.container.querySelector('.custom-control')).toBeInTheDocument() + await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveAttribute('data-checked', '') + }) +}) + +describe('RadioSkeleton', () => { + it('should render a visual placeholder without radio semantics', async () => { + const screen = await render() + const skeleton = screen.container.querySelector('.rounded-full') + + expect(screen.container.querySelector('[role="radio"]')).not.toBeInTheDocument() + await expect.element(skeleton).toHaveClass('rounded-full', 'opacity-20') + }) +}) diff --git a/packages/dify-ui/src/radio/index.stories.tsx b/packages/dify-ui/src/radio/index.stories.tsx new file mode 100644 index 0000000000..58af1bbbc1 --- /dev/null +++ b/packages/dify-ui/src/radio/index.stories.tsx @@ -0,0 +1,147 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ComponentProps } from 'react' +import { useState } from 'react' +import { + Radio, + RadioSkeleton, +} from '.' +import { FieldItem, FieldLabel, FieldRoot } from '../field' +import { FieldsetLegend, FieldsetRoot } from '../fieldset' +import { RadioGroup } from '../radio-group' + +const meta = { + title: 'Base/Form/Radio', + component: Radio, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Radio primitive built on Base UI. It preserves RadioGroup selection, hidden input, disabled, and form semantics while applying the Dify 16px radio design from Figma. Import from `@langgenius/dify-ui/radio` and place radios inside `RadioGroup` from `@langgenius/dify-ui/radio-group`.', + }, + }, + }, + tags: ['autodocs'], + args: { + disabled: false, + value: 'ssd', + }, + argTypes: { + disabled: { + control: 'boolean', + description: 'Disables user interaction and exposes Base UI disabled state attributes.', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function RadioDemo(args: Partial>) { + const [value, setValue] = useState('ssd') + + return ( + + + )} + > + Storage type + + + + SSD + + + + + + HDD + + + + + ) +} + +export const Default: Story = { + render: args => , + args: { + disabled: false, + }, +} + +export const Disabled: Story = { + args: { + value: 'checked', + }, + render: () => ( + + }> + Disabled states + + + + Disabled unchecked + + + + + + Disabled checked + + + + + ), +} + +export const StateMatrix: Story = { + args: { + value: 'checked', + }, + render: () => ( +
+ + }> + Radio states + + + + Unchecked + + + + + + Checked + + + + + + Disabled unchecked + + + + + + Disabled checked + + + + +
+
+
+ ), + parameters: { + docs: { + description: { + story: 'The full visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.', + }, + }, + }, +} diff --git a/packages/dify-ui/src/radio/index.tsx b/packages/dify-ui/src/radio/index.tsx new file mode 100644 index 0000000000..01d8ed5a16 --- /dev/null +++ b/packages/dify-ui/src/radio/index.tsx @@ -0,0 +1,105 @@ +'use client' + +import type { Radio as BaseRadioNS } from '@base-ui/react/radio' +import type { HTMLAttributes } from 'react' +import { Radio as BaseRadio } from '@base-ui/react/radio' +import { cn } from '../cn' + +const radioRootClassName = cn( + 'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none', + 'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3', + 'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover', + 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:ring-offset-0', + 'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover', + 'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled', + 'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled', + 'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled', + 'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled', +) + +const radioIndicatorClassName = 'flex items-center justify-center data-unchecked:hidden before:size-1.5 before:rounded-full before:bg-current' + +const radioControlClassName = radioRootClassName + +const radioSkeletonClassName = 'size-4 shrink-0 rounded-full bg-text-quaternary opacity-20' + +export type RadioRootProps + = Omit, 'className'> + & { + className?: string + variant?: 'control' | 'unstyled' + } + +export function RadioRoot({ + className, + variant = 'control', + ...props +}: RadioRootProps) { + return ( + + ) +} + +export type RadioIndicatorProps + = Omit + & { + className?: string + } + +export function RadioIndicator({ + className, + ...props +}: RadioIndicatorProps) { + return ( + + ) +} + +export type RadioControlProps + = Omit + +export function RadioControl({ + className, + ...props +}: RadioControlProps) { + return ( + + ) +} + +export type RadioProps + = Omit, 'children'> + +export function Radio({ + ...props +}: RadioProps) { + return +} + +export type RadioSkeletonProps + = Omit, 'className'> + & { + className?: string + } + +export function RadioSkeleton({ + className, + ...props +}: RadioSkeletonProps) { + return ( +
+ ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fd004ad6d..803e1e7f82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ catalogs: specifier: 9.0.0 version: 9.0.0 '@base-ui/react': - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.5.0 + version: 1.5.0 '@chromatic-com/storybook': specifier: 5.2.1 version: 5.2.1 @@ -741,7 +741,7 @@ importers: devDependencies: '@base-ui/react': specifier: 'catalog:' - version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@chromatic-com/storybook': specifier: 'catalog:' version: 5.2.1(storybook@10.4.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.21(@types/node@25.9.0)(@vitest/coverage-v8@4.1.6(@types/node@25.9.0)(@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.9.0)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.9.0)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))) @@ -900,7 +900,7 @@ importers: version: 1.30.4(@amplitude/rrweb@2.0.0-alpha.40) '@base-ui/react': specifier: 'catalog:' - version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@emoji-mart/data': specifier: 'catalog:' version: 1.2.1 @@ -1652,8 +1652,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.4.1': - resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==} + '@base-ui/react@1.5.0': + resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -1669,8 +1669,8 @@ packages: date-fns: optional: true - '@base-ui/utils@0.2.8': - resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==} + '@base-ui/utils@0.2.9': + resolution: {integrity: sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -7926,8 +7926,8 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} @@ -9239,10 +9239,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@base-ui/react@1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@base-ui/utils': 0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@floating-ui/utils': 0.2.11 react: 19.2.6 @@ -9251,13 +9251,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@base-ui/utils@0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 '@floating-ui/utils': 0.2.11 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - reselect: 5.1.1 + reselect: 5.2.0 use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 @@ -16002,7 +16002,7 @@ snapshots: require-directory@2.1.1: {} - reselect@5.1.1: {} + reselect@5.2.0: {} reserved-identifiers@1.2.0: {} @@ -17028,7 +17028,7 @@ time: '@amplitude/analytics-browser@2.42.3': '2026-05-13T17:32:46.705Z' '@amplitude/plugin-session-replay-browser@1.30.4': '2026-05-14T00:11:02.360Z' '@antfu/eslint-config@9.0.0': '2026-05-11T06:18:58.474Z' - '@base-ui/react@1.4.1': '2026-04-20T12:24:35.520Z' + '@base-ui/react@1.5.0': '2026-05-19T13:22:48.843Z' '@chromatic-com/storybook@5.2.1': '2026-05-14T07:49:29.364Z' '@cucumber/cucumber@12.9.0': '2026-05-15T16:02:12.674Z' '@egoist/tailwindcss-icons@1.9.2': '2026-01-31T10:48:44.594Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 148f3d014b..3f99e464b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,7 +63,7 @@ catalog: '@amplitude/analytics-browser': 2.42.3 '@amplitude/plugin-session-replay-browser': 1.30.4 '@antfu/eslint-config': 9.0.0 - '@base-ui/react': 1.4.1 + '@base-ui/react': 1.5.0 '@chromatic-com/storybook': 5.2.1 '@cucumber/cucumber': 12.9.0 '@egoist/tailwindcss-icons': 1.9.2 diff --git a/web/README.md b/web/README.md index 1748ed6947..1a0e526a26 100644 --- a/web/README.md +++ b/web/README.md @@ -167,7 +167,7 @@ The Dify community can be found on [Discord community], where you can ask questi [Storybook]: https://storybook.js.org [Vite+]: https://viteplus.dev [Vitest]: https://vitest.dev -[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx +[index.spec.tsx]: ./app/components/base/action-button/__tests__/index.spec.tsx [pnpm]: https://pnpm.io [vinext]: https://github.com/cloudflare/vinext [web/docs/test.md]: ./docs/test.md diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index fa5a40f8a4..b06d92e8d9 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -134,7 +134,7 @@ const DropDown = ({ render={( )} > diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx index 36fc74a6fd..89f02efe6c 100644 --- a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx +++ b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx @@ -2,11 +2,15 @@ import type { FC } from 'react' import type { AgentConfig } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' +import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' +import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset' import { Popover, PopoverContent, PopoverTrigger, } from '@langgenius/dify-ui/popover' +import { Radio } from '@langgenius/dify-ui/radio' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { RiArrowDownSLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' @@ -15,7 +19,6 @@ import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows import { Settings04 } from '@/app/components/base/icons/src/vender/line/general' import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication' import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education' -import Radio from '@/app/components/base/radio/ui' import AgentSetting from '../agent/agent-setting' type Props = { @@ -35,26 +38,26 @@ type ItemProps = { isChecked: boolean description: string Icon: any - onClick: (value: string) => void } -const SelectItem: FC = ({ text, value, Icon, isChecked, description, onClick, disabled }) => { +const SelectItem: FC = ({ text, value, Icon, isChecked, description, disabled }) => { return ( -
!disabled && onClick(value)} - > -
-
-
- + + +
+
+
+ +
+
{text}
-
{text}
+
- -
-
{description}
-
+
{description}
+ + ) } @@ -127,25 +130,38 @@ const AssistantTypePicker: FC = ({ alignOffset={-2} popupClassName="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg" > -
{t('assistantType.name', { ns: 'appDebug' })}
- - + + + )} + > + + {t('assistantType.name', { ns: 'appDebug' })} + + + + + {!disabled && agentConfigUI} diff --git a/web/app/components/app/configuration/config/automatic/version-selector.tsx b/web/app/components/app/configuration/config/automatic/version-selector.tsx index 60584f1a72..cb4f2cb9f5 100644 --- a/web/app/components/app/configuration/config/automatic/version-selector.tsx +++ b/web/app/components/app/configuration/config/automatic/version-selector.tsx @@ -7,9 +7,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' import * as React from 'react' -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' type VersionSelectorProps = { @@ -20,17 +18,7 @@ type VersionSelectorProps = { const VersionSelector: React.FC = ({ versionLen, value, onChange }) => { const { t } = useTranslation() - const [isOpen, { - setFalse: handleOpenFalse, - set: handleOpenSet, - }] = useBoolean(false) - const moreThanOneVersion = versionLen > 1 - const handleOpen = useCallback((nextOpen: boolean) => { - if (moreThanOneVersion) - handleOpenSet(nextOpen) - }, [moreThanOneVersion, handleOpenSet]) - const versions = Array.from({ length: versionLen }, (_, index) => ({ label: `${t('generate.version', { ns: 'appDebug' })} ${index + 1}${index === versionLen - 1 ? ` · ${t('generate.latest', { ns: 'appDebug' })}` : ''}`, value: index, @@ -39,14 +27,12 @@ const VersionSelector: React.FC = ({ versionLen, value, on const isLatest = value === versionLen - 1 return ( - + + disabled={!moreThanOneVersion} + className={cn( + 'flex items-center border-none bg-transparent p-0 system-xs-medium text-text-tertiary', + moreThanOneVersion ? 'cursor-pointer data-popup-open:text-text-secondary' : 'cursor-default', )} >
@@ -63,14 +49,13 @@ const VersionSelector: React.FC = ({ versionLen, value, on alignOffset={-12} popupClassName="w-[208px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > -
+
{t('generate.versions', { ns: 'appDebug' })}
{ onChange(nextValue) - handleOpenFalse() }} > {versions.map(option => ( diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index 064a1fb97f..db79804755 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -1,6 +1,5 @@ import type { CSSProperties, FC } from 'react' import type { ModelAndParameter } from '../types' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -8,7 +7,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { memo, useState } from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -42,10 +41,8 @@ const DebugItem: FC = ({ const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id) const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider) const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model) - const [open, setOpen] = useState(false) const handleDuplicate = () => { - setOpen(false) if (multipleModelConfigs.length >= 4) return @@ -63,12 +60,10 @@ const DebugItem: FC = ({ } const handleDebugAsSingleModel = () => { - setOpen(false) onDebugWithMultipleModelChange(modelAndParameter) } const handleRemove = () => { - setOpen(false) onMultipleModelConfigsChange( true, multipleModelConfigs.filter(item => item.id !== modelAndParameter.id), @@ -92,11 +87,11 @@ const DebugItem: FC = ({ - + diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index d1b7dedac3..17194796f4 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -17,6 +17,20 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) +const mockConfig = vi.hoisted(() => ({ + isCloudEdition: true, +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + get IS_CLOUD_EDITION() { + return mockConfig.isCloudEdition + }, + } +}) + const mockApp: App = { can_trial: true, app: { @@ -70,6 +84,7 @@ describe('AppCard', () => { } beforeEach(() => { + mockConfig.isCloudEdition = true vi.clearAllMocks() }) @@ -261,6 +276,13 @@ describe('AppCard', () => { app: mockApp, }) }) + + it('should hide try button outside cloud edition', () => { + mockConfig.isCloudEdition = false + renderWithProvider() + + expect(screen.queryByRole('button', { name: /explore\.appCard\.try/ })).not.toBeInTheDocument() + }) }) describe('Keyboard Accessibility', () => { diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 1b022eb961..899306c20a 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -4,14 +4,13 @@ import { PlusIcon } from '@heroicons/react/20/solid' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiInformation2Line } from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useContextSelector } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' +import { IS_CLOUD_EDITION } from '@/config' import AppListContext from '@/context/app-list-context' -import { systemFeaturesQueryOptions } from '@/service/system-features' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' type AppCardProps = { @@ -27,8 +26,7 @@ const AppCard = ({ }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const isTrialApp = app.can_trial && systemFeatures.enable_trial_app + const canViewApp = IS_CLOUD_EDITION const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) const handleShowTryAppPanel = useCallback(() => { trackEvent('preview_template', { @@ -69,19 +67,21 @@ const AppCard = ({ {app.description}
- {(canCreate || isTrialApp) && ( + {(canCreate || canViewApp) && (
@@ -156,7 +151,6 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => { hideLogout={isInstalledApp} placement="top-start" data={appData?.site} - forceClose={isPanel && !panelVisible} /> {/* powered by */}
diff --git a/web/app/components/base/chip/__tests__/index.spec.tsx b/web/app/components/base/chip/__tests__/index.spec.tsx index 40ecbb7a33..59c23c4a01 100644 --- a/web/app/components/base/chip/__tests__/index.spec.tsx +++ b/web/app/components/base/chip/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { Item } from '../index' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import Chip from '../index' @@ -27,27 +28,39 @@ describe('Chip', () => { // Helper function to render Chip with default props const renderChip = (props: Partial> = {}) => { - return render( - , - ) + const user = userEvent.setup() + return { + user, + ...render( + , + ), + } } // Helper function to get the trigger element const getTrigger = (container: HTMLElement) => { - return container.querySelector('[role="button"][aria-haspopup="menu"]') as HTMLElement | null + return container.querySelector('button[role="combobox"]') as HTMLElement | null } // Helper function to open dropdown panel - const openPanel = (container: HTMLElement) => { + const openPanel = async (user: ReturnType, container: HTMLElement) => { const trigger = getTrigger(container) - if (trigger) - fireEvent.click(trigger) + expect(trigger).toBeInTheDocument() + await user.click(trigger!) + return screen.findByRole('listbox') + } + + const expectPanelClosed = async (trigger: HTMLElement | null) => { + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(trigger).not.toHaveAttribute('data-popup-open') + }) } describe('Rendering', () => { @@ -60,7 +73,7 @@ describe('Chip', () => { it('should display current selected item name', () => { renderChip({ value: 'active' }) - expect(screen.getByText('Active'))!.toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Active' }))!.toBeInTheDocument() }) it('should display empty content when value does not match any item', () => { @@ -86,27 +99,22 @@ describe('Chip', () => { onClear={onClear} />, ) - expect(screen.getByText('Archived'))!.toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Archived' }))!.toBeInTheDocument() }) it('should show left icon by default', () => { const { container } = renderChip() - // The filter icon should be visible - const svg = container.querySelector('svg') - expect(svg)!.toBeInTheDocument() + expect(container.querySelector('.i-ri-filter-3-line')).toBeInTheDocument() }) it('should hide left icon when showLeftIcon is false', () => { - renderChip({ showLeftIcon: false }) + renderChip({ showLeftIcon: false, value: '' }) // When showLeftIcon is false, there should be no filter icon before the text - const textElement = screen.getByText('All Items') - const parent = textElement.closest('[role="button"]') - const icons = parent?.querySelectorAll('svg') - - // Should only have the arrow icon, not the filter icon - expect(icons?.length).toBe(1) + const trigger = getTrigger(document.body) + expect(trigger?.querySelector('.i-ri-filter-3-line')).not.toBeInTheDocument() + expect(trigger?.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() }) it('should render custom left icon', () => { @@ -126,11 +134,11 @@ describe('Chip', () => { expect(chipElement)!.toBeInTheDocument() }) - it('should apply custom panelClassName to dropdown panel', () => { + it('should apply custom panelClassName to dropdown panel', async () => { const customPanelClass = 'custom-panel-class' - const { container } = renderChip({ panelClassName: customPanelClass }) - openPanel(container) + const { container, user } = renderChip({ panelClassName: customPanelClass }) + await openPanel(user, container) // Panel is rendered in a portal, so check document.body const panel = document.body.querySelector(`.${customPanelClass}`) @@ -139,110 +147,90 @@ describe('Chip', () => { }) describe('State Management', () => { - it('should toggle dropdown panel on trigger click', () => { - const { container } = renderChip() + it('should toggle dropdown panel on trigger click', async () => { + const { container, user } = renderChip() - // Initially closed - check aria-expanded attribute const trigger = getTrigger(container) - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(trigger).not.toHaveAttribute('data-popup-open') - // Open panel - openPanel(container) - expect(trigger)!.toHaveAttribute('aria-expanded', 'true') - // Panel items should be visible - expect(screen.getAllByText('All Items').length).toBeGreaterThan(1) + const listbox = await openPanel(user, container) + expect(trigger).toHaveAttribute('data-popup-open') + expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument() - // Close panel if (trigger) - fireEvent.click(trigger) - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + await user.click(trigger) + await expectPanelClosed(trigger) }) - it('should close panel after selecting an item', () => { - const { container } = renderChip() + it('should close panel after selecting an item', async () => { + const { container, user } = renderChip() - openPanel(container) + const listbox = await openPanel(user, container) const trigger = getTrigger(container) - expect(trigger)!.toHaveAttribute('aria-expanded', 'true') + expect(trigger).toHaveAttribute('data-popup-open') - // Click on an item in the dropdown panel - const activeItems = screen.getAllByText('Active') - // The second one should be in the dropdown - fireEvent.click(activeItems[activeItems.length - 1]!) + await user.click(within(listbox).getByRole('option', { name: 'Active' })) - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + await expectPanelClosed(trigger) }) }) describe('Event Handlers', () => { - it('should call onSelect with correct item when item is clicked', () => { - const { container } = renderChip() + it('should call onSelect with correct item when item is clicked', async () => { + const { container, user } = renderChip() - openPanel(container) - // Get all "Active" texts and click the one in the dropdown (should be the last one) - const activeItems = screen.getAllByText('Active') - fireEvent.click(activeItems[activeItems.length - 1]!) + const listbox = await openPanel(user, container) + await user.click(within(listbox).getByRole('option', { name: 'Active' })) expect(onSelect).toHaveBeenCalledTimes(1) expect(onSelect).toHaveBeenCalledWith(items[1]) }) - it('should call onClear when clear button is clicked', () => { - const { container } = renderChip({ value: 'active' }) + it('should call onClear when clear button is clicked', async () => { + const { user } = renderChip({ value: 'active' }) - // Find the close icon (last SVG in the trigger) and click its parent - const trigger = getTrigger(container) - const svgs = trigger?.querySelectorAll('svg') - // The close icon should be the last SVG element - const closeIcon = svgs?.[svgs.length - 1] - const clearButton = closeIcon?.parentElement + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) - expect(clearButton)!.toBeInTheDocument() - if (clearButton) - fireEvent.click(clearButton) + await user.click(clearButton) expect(onClear).toHaveBeenCalledTimes(1) }) - it('should stop event propagation when clear button is clicked', () => { - const { container } = renderChip({ value: 'active' }) + it('should stop event propagation when clear button is clicked', async () => { + const { container, user } = renderChip({ value: 'active' }) const trigger = getTrigger(container) - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(trigger).not.toHaveAttribute('data-popup-open') - // Find the close icon (last SVG) and click its parent - const svgs = trigger?.querySelectorAll('svg') - const closeIcon = svgs?.[svgs.length - 1] - const clearButton = closeIcon?.parentElement + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) - if (clearButton) - fireEvent.click(clearButton) + await user.click(clearButton) - // Panel should remain closed - // Panel should remain closed - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(trigger).not.toHaveAttribute('data-popup-open') expect(onClear).toHaveBeenCalledTimes(1) }) - it('should handle multiple rapid clicks on trigger', () => { - const { container } = renderChip() + it('should handle multiple rapid clicks on trigger', async () => { + const { container, user } = renderChip() const trigger = getTrigger(container) - // Click 1: open if (trigger) - fireEvent.click(trigger) - expect(trigger)!.toHaveAttribute('aria-expanded', 'true') + await user.click(trigger) + expect(await screen.findByRole('listbox')).toBeInTheDocument() + expect(trigger).toHaveAttribute('data-popup-open') - // Click 2: close if (trigger) - fireEvent.click(trigger) - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + await user.click(trigger) + await expectPanelClosed(trigger) - // Click 3: open again if (trigger) - fireEvent.click(trigger) - expect(trigger)!.toHaveAttribute('aria-expanded', 'true') + await user.click(trigger) + expect(await screen.findByRole('listbox')).toBeInTheDocument() + expect(trigger).toHaveAttribute('data-popup-open') }) }) @@ -250,17 +238,13 @@ describe('Chip', () => { it('should show arrow down icon when no value is selected', () => { const { container } = renderChip({ value: '' }) - // Should have SVG icons (filter icon and arrow down icon) - const svgs = container.querySelectorAll('svg') - expect(svgs.length).toBeGreaterThan(0) + expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() }) it('should show clear button when value is selected', () => { const { container } = renderChip({ value: 'active' }) - // When value is selected, there should be an icon (the close icon) - const svgs = container.querySelectorAll('svg') - expect(svgs.length).toBeGreaterThan(0) + expect(container.querySelector('.i-ri-close-circle-fill')).toBeInTheDocument() }) it('should not show clear button when no value is selected', () => { @@ -268,57 +252,43 @@ describe('Chip', () => { const trigger = getTrigger(container) - // When value is empty, the trigger should only have 2 SVGs (filter icon + arrow) - // When value is selected, it would have 2 SVGs (filter icon + close icon) - const svgs = trigger?.querySelectorAll('svg') - // Arrow icon should be present, close icon should not - expect(svgs?.length).toBe(2) + expect(trigger?.querySelector('.i-ri-filter-3-line')).toBeInTheDocument() + expect(trigger?.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() + expect(container.querySelector('.i-ri-close-circle-fill')).not.toBeInTheDocument() // Verify onClear hasn't been called expect(onClear).not.toHaveBeenCalled() }) - it('should show dropdown content only when panel is open', () => { - const { container } = renderChip() + it('should show dropdown content only when panel is open', async () => { + const { container, user } = renderChip() const trigger = getTrigger(container) - // Closed by default - // Closed by default - expect(trigger)!.toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(trigger).not.toHaveAttribute('data-popup-open') - openPanel(container) - expect(trigger)!.toHaveAttribute('aria-expanded', 'true') - // Items should be duplicated (once in trigger, once in panel) - expect(screen.getAllByText('All Items').length).toBeGreaterThan(1) + const listbox = await openPanel(user, container) + expect(trigger).toHaveAttribute('data-popup-open') + expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument() }) - it('should show check icon on selected item in dropdown', () => { - const { container } = renderChip({ value: 'active' }) + it('should show check icon on selected item in dropdown', async () => { + const { container, user } = renderChip({ value: 'active' }) - openPanel(container) + const listbox = await openPanel(user, container) - // Find the dropdown panel items - const allActiveTexts = screen.getAllByText('Active') - // The dropdown item should be the last one - const dropdownItem = allActiveTexts[allActiveTexts.length - 1] - const parentContainer = dropdownItem!.parentElement - - // The check icon should be a sibling within the parent - const checkIcon = parentContainer?.querySelector('svg') - expect(checkIcon)!.toBeInTheDocument() + expect(within(listbox).getByRole('option', { name: 'Active' })).toHaveAttribute('aria-selected', 'true') }) - it('should render all items in dropdown when open', () => { - const { container } = renderChip() + it('should render all items in dropdown when open', async () => { + const { container, user } = renderChip() - openPanel(container) + const listbox = await openPanel(user, container) - // Each item should appear at least twice (once in potential selected state, once in dropdown) - // Use getAllByText to handle multiple occurrences - expect(screen.getAllByText('All Items').length).toBeGreaterThan(0) - expect(screen.getAllByText('Active').length).toBeGreaterThan(0) - expect(screen.getAllByText('Archived').length).toBeGreaterThan(0) + expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument() + expect(within(listbox).getByRole('option', { name: 'Active' })).toBeInTheDocument() + expect(within(listbox).getByRole('option', { name: 'Archived' })).toBeInTheDocument() }) }) @@ -339,56 +309,65 @@ describe('Chip', () => { // The trigger should not display any item name text expect(trigger?.textContent?.trim()).toBeFalsy() + expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument() }) - it('should allow selecting already selected item', () => { - const { container } = renderChip({ value: 'active' }) + it('should allow selecting already selected item', async () => { + const { container, user } = renderChip({ value: 'active' }) - openPanel(container) + const listbox = await openPanel(user, container) - // Click on the already selected item in the dropdown - const activeItems = screen.getAllByText('Active') - fireEvent.click(activeItems[activeItems.length - 1]!) + await user.click(within(listbox).getByRole('option', { name: 'Active' })) expect(onSelect).toHaveBeenCalledTimes(1) expect(onSelect).toHaveBeenCalledWith(items[1]) }) - it('should handle numeric values', () => { + it('should handle numeric values', async () => { const numericItems: Item[] = [ { value: 1, name: 'First' }, { value: 2, name: 'Second' }, { value: 3, name: 'Third' }, ] - const { container } = renderChip({ value: 2, items: numericItems }) + const { container, user } = renderChip({ value: 2, items: numericItems }) expect(screen.getByText('Second'))!.toBeInTheDocument() // Open panel and select Third - openPanel(container) + const listbox = await openPanel(user, container) - const thirdItems = screen.getAllByText('Third') - fireEvent.click(thirdItems[thirdItems.length - 1]!) + await user.click(within(listbox).getByRole('option', { name: 'Third' })) expect(onSelect).toHaveBeenCalledWith(numericItems[2]) }) - it('should handle items with additional properties', () => { + it('should treat numeric zero as a selected value', () => { + const numericItems: Item[] = [ + { value: 0, name: 'Zero' }, + { value: 1, name: 'One' }, + ] + + renderChip({ value: 0, items: numericItems }) + + expect(screen.getByRole('combobox', { name: 'Zero' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument() + }) + + it('should handle items with additional properties', async () => { const itemsWithExtra: Item[] = [ { value: 'a', name: 'Item A', customProp: 'extra1' }, { value: 'b', name: 'Item B', customProp: 'extra2' }, ] - const { container } = renderChip({ value: 'a', items: itemsWithExtra }) + const { container, user } = renderChip({ value: 'a', items: itemsWithExtra }) expect(screen.getByText('Item A'))!.toBeInTheDocument() // Open panel and select Item B - openPanel(container) + const listbox = await openPanel(user, container) - const itemBs = screen.getAllByText('Item B') - fireEvent.click(itemBs[itemBs.length - 1]!) + await user.click(within(listbox).getByRole('option', { name: 'Item B' })) expect(onSelect).toHaveBeenCalledWith(itemsWithExtra[1]) }) diff --git a/web/app/components/base/chip/index.tsx b/web/app/components/base/chip/index.tsx index 6ba5d9cb44..d009159f44 100644 --- a/web/app/components/base/chip/index.tsx +++ b/web/app/components/base/chip/index.tsx @@ -1,31 +1,34 @@ -import type { FC } from 'react' +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react' -import { useMemo, useState } from 'react' + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { useTranslation } from 'react-i18next' -export type Item = { - value: number | string +type ItemValue = number | string + +export type Item = { + value: T name: string -} & Record +} & Record -type Props = { +type Props = { className?: string panelClassName?: string showLeftIcon?: boolean - leftIcon?: any - value: number | string - items: Item[] - onSelect: (item: any) => void + leftIcon?: ReactNode + value: T + items: Item[] + onSelect: (item: Item) => void onClear: () => void } -const Chip: FC = ({ + +function Chip({ className, panelClassName, showLeftIcon = true, @@ -34,86 +37,84 @@ const Chip: FC = ({ items, onSelect, onClear, -}) => { - const [open, setOpen] = useState(false) - - const triggerContent = useMemo(() => { - return items.find(item => item.value === value)?.name || '' - }, [items, value]) +}: Props) { + const { t } = useTranslation() + const selectedItem = items.find(item => Object.is(item.value, value)) + const triggerContent = selectedItem?.name || '' + const hasValue = selectedItem !== undefined && value !== '' return ( - items.find(item => Object.is(item.value, itemValue))?.name ?? ''} + itemToStringValue={itemValue => String(itemValue)} + onValueChange={(nextValue) => { + if (nextValue === null) + return + const selected = items.find(item => Object.is(item.value, nextValue)) + if (selected) + onSelect(selected) + }} > -
- } - > -
+ *:last-child]:hidden', + hasValue && 'border-components-button-secondary-border! bg-components-button-secondary-bg! pr-6 shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! data-popup-open:border-components-button-secondary-border-hover! data-popup-open:bg-components-button-secondary-bg-hover! data-popup-open:hover:border-components-button-secondary-border-hover data-popup-open:hover:bg-components-button-secondary-bg-hover!', className, )} - > + > + {showLeftIcon && ( -
+ {leftIcon || ( - + )} -
+
)} -
-
+ + {triggerContent} -
-
- {!value && } - {!!value && ( -
{ - e.stopPropagation() - onClear() - }} - > - -
- )} -
-
- + + {!hasValue && } + + + {hasValue && ( + + )} + - { - const selected = items.find(item => item.value === nextValue) - if (selected) - onSelect(selected) - }} - className="max-h-72 overflow-auto p-1" - > - {items.map(item => ( - -
{item.name}
- {value === item.value && } -
- ))} -
-
+ {items.map(item => ( + + + {item.name} + + + + ))} +
-
+ ) } diff --git a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx index 06865dde70..c99f20f842 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx @@ -8,10 +8,13 @@ import type { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { FieldRoot } from '@langgenius/dify-ui/field' +import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset' +import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { produce } from 'immer' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Radio from '@/app/components/base/radio/ui' import Textarea from '@/app/components/base/textarea' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -145,22 +148,30 @@ const FollowUpSettingModal = ({ hideDebugWithMultipleModel />
-
-
- {t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })} -
-
-
{promptMode === PROMPT_MODE.default && (
@@ -182,18 +191,18 @@ const FollowUpSettingModal = ({
)} - -
{promptMode === PROMPT_MODE.custom && (