diff --git a/api/.env.example b/api/.env.example index 3aa107130f9..48d8707d1ad 100644 --- a/api/.env.example +++ b/api/.env.example @@ -36,6 +36,9 @@ FILES_ACCESS_TIMEOUT=300 # Collaboration mode toggle ENABLE_COLLABORATION_MODE=true +# Learn app feature toggle +ENABLE_LEARN_APP=true + # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index dc8c840da9c..f664274ba75 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1073,6 +1073,12 @@ class MailConfig(BaseSettings): default=None, ) + +class HomepageConfig(BaseSettings): + """ + Configuration for homepage feature toggles exposed through system features. + """ + ENABLE_TRIAL_APP: bool = Field( description="Enable trial app", default=False, @@ -1083,6 +1089,11 @@ class MailConfig(BaseSettings): default=False, ) + ENABLE_LEARN_APP: bool = Field( + description="Enable Learn App", + default=True, + ) + class RagEtlConfig(BaseSettings): """ @@ -1489,6 +1500,7 @@ class FeatureConfig( EndpointConfig, FileAccessConfig, FileUploadConfig, + HomepageConfig, HttpConfig, InnerAPIConfig, IndexingConfig, diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index e52e1e9c9da..6ca4053d7de 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -31,6 +31,7 @@ from core.app.entities.queue_entities import ( QueueNodeStartedEvent, QueueNodeSucceededEvent, QueuePingEvent, + QueueReasoningChunkEvent, QueueStopEvent, QueueTextChunkEvent, QueueWorkflowFailedEvent, @@ -47,6 +48,7 @@ from core.app.entities.task_entities import ( MessageAudioEndStreamResponse, MessageAudioStreamResponse, PingStreamResponse, + ReasoningChunkStreamResponse, StreamResponse, TextChunkStreamResponse, WorkflowAppBlockingResponse, @@ -571,6 +573,22 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): yield self._text_chunk_to_stream_response(delta_text, from_variable_selector=event.from_variable_selector) + def _handle_reasoning_chunk_event( + self, event: QueueReasoningChunkEvent, **kwargs + ) -> Generator[StreamResponse, None, None]: + """Handle reasoning chunk events.""" + # is_final with empty reasoning is still forwarded as the "thinking finished" signal + if not event.reasoning and not event.is_final: + return + yield ReasoningChunkStreamResponse( + task_id=self._application_generate_entity.task_id, + data=ReasoningChunkStreamResponse.Data( + reasoning=event.reasoning, + node_id=event.from_node_id, + is_final=event.is_final, + ), + ) + def _handle_agent_log_event(self, event: QueueAgentLogEvent, **kwargs) -> Generator[StreamResponse, None, None]: """Handle agent log events.""" yield self._workflow_response_converter.handle_agent_log( @@ -600,6 +618,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): QueuePingEvent: self._handle_ping_event, QueueErrorEvent: self._handle_error_event, QueueTextChunkEvent: self._handle_text_chunk_event, + QueueReasoningChunkEvent: self._handle_reasoning_chunk_event, # Workflow events QueueWorkflowStartedEvent: self._handle_workflow_started_event, QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event, diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index f98fe6fb0be..ca5a26db55b 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -743,7 +743,8 @@ class ReasoningChunkStreamResponse(StreamResponse): Data entity """ - message_id: str + # chat apps set this; workflow runs have no message + message_id: str | None = None reasoning: str node_id: str | None = None is_final: bool = False diff --git a/api/migrations/versions/2026_06_23_1800-d9e8f7a6b5c4_add_cloud_only_flag_to_recommended_apps.py b/api/migrations/versions/2026_06_23_1800-d9e8f7a6b5c4_add_cloud_only_flag_to_recommended_apps.py new file mode 100644 index 00000000000..77bf5118bec --- /dev/null +++ b/api/migrations/versions/2026_06_23_1800-d9e8f7a6b5c4_add_cloud_only_flag_to_recommended_apps.py @@ -0,0 +1,26 @@ +"""add cloud only flag to recommended apps + +Revision ID: d9e8f7a6b5c4 +Revises: c8f4a6b2d3e1 +Create Date: 2026-06-23 18:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d9e8f7a6b5c4" +down_revision = "c8f4a6b2d3e1" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_cloud_only", sa.Boolean(), server_default=sa.text("false"), nullable=False)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("is_cloud_only") diff --git a/api/models/model.py b/api/models/model.py index 947cbf6fe4a..38d67004de4 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -925,6 +925,9 @@ class RecommendedApp(TypeBase): is_learn_dify: Mapped[bool] = mapped_column( sa.Boolean, nullable=False, server_default=sa.text("false"), default=False ) + is_cloud_only: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) language: Mapped[str] = mapped_column( String(255), diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f20b8b15245..347b6ef33f4 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -19692,6 +19692,7 @@ Model class for provider system configuration response. | enable_email_code_login | boolean | | Yes | | enable_email_password_login | boolean,
**Default:** true | | Yes | | enable_explore_banner | boolean | | Yes | +| enable_learn_app | boolean,
**Default:** true | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | | enable_trial_app | boolean | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 569e3706caa..0f368895ab6 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1603,6 +1603,7 @@ Default configuration for form inputs. | enable_email_code_login | boolean | | Yes | | enable_email_password_login | boolean,
**Default:** true | | Yes | | enable_explore_banner | boolean | | Yes | +| enable_learn_app | boolean,
**Default:** true | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | | enable_trial_app | boolean | | Yes | diff --git a/api/providers/trace/trace-langsmith/pyproject.toml b/api/providers/trace/trace-langsmith/pyproject.toml index 80eb9ae3238..618bec79d0e 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.5", + "langsmith==0.8.18", ] description = "Dify ops tracing provider (LangSmith)." diff --git a/api/pyproject.toml b/api/pyproject.toml index 6cd1cdb484d..4050944d573 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -103,7 +103,11 @@ dify-trace-weave = { workspace = true } [tool.uv] default-groups = ["storage", "tools", "vdb-all", "trace-all"] package = false -override-dependencies = ["litellm>=1.83.10,<2.0.0", "pyarrow>=23.0.1,<24.0.0"] +override-dependencies = [ + "litellm>=1.83.10,<2.0.0", + "pyarrow>=23.0.1,<24.0.0", + "cryptography>=49.0.0,<50.0.0", +] [dependency-groups] diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 2ae0c63ff75..c9d86ee4578 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -181,6 +181,7 @@ class SystemFeatureModel(FeatureResponseModel): enable_creators_platform: bool = False enable_trial_app: bool = False enable_explore_banner: bool = False + enable_learn_app: bool = True rbac_enabled: bool = False @@ -282,6 +283,7 @@ class FeatureService: system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER + system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP @classmethod def _fulfill_trial_models_from_env(cls) -> list[str]: diff --git a/api/services/recommend_app/buildin/buildin_retrieval.py b/api/services/recommend_app/buildin/buildin_retrieval.py index e48286303cb..03b72a4f57c 100644 --- a/api/services/recommend_app/buildin/buildin_retrieval.py +++ b/api/services/recommend_app/buildin/buildin_retrieval.py @@ -5,6 +5,7 @@ from typing import Any, override from flask import current_app +from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType @@ -25,6 +26,11 @@ class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase): result = self.fetch_recommended_apps_from_builtin(language) return result + @override + def get_learn_dify_apps(self, language: str): + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + return result + @override def get_recommend_app_detail(self, app_id: str): result = self.fetch_recommended_app_detail_from_builtin(app_id) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 9d6c28c2117..f6786175896 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -49,6 +49,11 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): result = self.fetch_recommended_apps_from_db(language) return result + @override + def get_learn_dify_apps(self, language: str) -> RecommendedAppsResultDict: + result = self.fetch_learn_dify_apps_from_db(language) + return result + @override def get_recommend_app_detail(self, app_id: str) -> RecommendedAppDetailDict | None: result = self.fetch_recommended_app_detail_from_db(app_id) diff --git a/api/services/recommend_app/recommend_app_base.py b/api/services/recommend_app/recommend_app_base.py index 4214d56e4aa..f819cc3a937 100644 --- a/api/services/recommend_app/recommend_app_base.py +++ b/api/services/recommend_app/recommend_app_base.py @@ -6,6 +6,8 @@ class RecommendAppRetrievalBase(Protocol): def get_recommended_apps_and_categories(self, language: str) -> Any: ... + def get_learn_dify_apps(self, language: str) -> Any: ... + def get_recommend_app_detail(self, app_id: str) -> Any: ... def get_type(self) -> str: ... diff --git a/api/services/recommend_app/remote/remote_retrieval.py b/api/services/recommend_app/remote/remote_retrieval.py index 890fb132faa..2e3222bb978 100644 --- a/api/services/recommend_app/remote/remote_retrieval.py +++ b/api/services/recommend_app/remote/remote_retrieval.py @@ -2,15 +2,28 @@ import logging from typing import Any, override import httpx +from flask import has_request_context, request from configs import dify_config from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval +from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType logger = logging.getLogger(__name__) +def _current_origin_headers() -> dict[str, str]: + origin = request.headers.get("Origin") if has_request_context() else None + if origin: + return {"Origin": origin} + + console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "") + if not isinstance(console_web_url, str) or not console_web_url: + return {} + return {"Origin": console_web_url} + + class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ Retrieval recommended app from dify official. @@ -37,6 +50,15 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language) return result + @override + def get_learn_dify_apps(self, language: str): + try: + result = self.fetch_learn_dify_apps_from_dify_official(language) + except Exception as e: + logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e) + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + return result + @override def get_type(self) -> str: return RecommendAppType.REMOTE @@ -50,7 +72,7 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN url = f"{domain}/apps/{app_id}" - response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0)) + response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0)) if response.status_code != 200: return None data: dict[str, Any] = response.json() @@ -65,9 +87,25 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN url = f"{domain}/apps?language={language}" - response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0)) + response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0)) if response.status_code != 200: raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}") result: dict[str, Any] = response.json() return result + + @classmethod + def fetch_learn_dify_apps_from_dify_official(cls, language: str): + """ + Fetch Learn Dify apps from dify official. + :param language: language + :return: + """ + domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN + url = f"{domain}/apps/learn-dify?language={language}" + response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0)) + if response.status_code != 200: + raise ValueError(f"fetch learn dify apps failed, status code: {response.status_code}") + + result: dict[str, Any] = response.json() + return result diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index bc8bb58acba..2d247ba5b71 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -6,7 +6,6 @@ from sqlalchemy.orm import scoped_session from configs import dify_config from models.model import AccountTrialAppRecord, TrialApp from services.feature_service import FeatureService -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory @@ -38,11 +37,13 @@ class RecommendedAppService: @classmethod def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]: """ - Get database-backed recommended apps marked as Learn Dify. + Get recommended apps marked for the Learn Dify section. :param language: language :return: """ - result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE + retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)() + result = retrieval_instance.get_learn_dify_apps(language) if FeatureService.get_system_features().enable_trial_app: for app in result["recommended_apps"]: diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py index 750e35843be..9b8eec08ef4 100644 --- a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -267,36 +267,45 @@ class TestRecommendedAppServiceGetDetail: class TestRecommendedAppServiceGetLearnDifyApps: - def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("services.recommended_app_service.FeatureService", autospec=True) + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config") + def test_uses_configured_retrieval_source( + self, mock_config: MagicMock, mock_factory_class: MagicMock, mock_feature_service: MagicMock + ) -> None: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False) expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow") - mock_database_retrieval = MagicMock() - mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + mock_instance = MagicMock() + mock_instance.get_learn_dify_apps.return_value = { "recommended_apps": [expected_app], "categories": ["Workflow"], } - monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), - ) - factory_mock = MagicMock() - monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock) + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US") assert result == {"recommended_apps": [expected_app]} - mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US") - factory_mock.assert_not_called() + mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") + mock_instance.get_learn_dify_apps.assert_called_once_with("en-US") - def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("services.recommended_app_service.dify_config") + def test_sets_can_trial_when_trial_feature_enabled( + self, mock_config: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" app = RecommendedAppPayload(app_id="app-1", category="Workflow") - mock_database_retrieval = MagicMock() - mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + mock_retrieval_instance = MagicMock() + mock_retrieval_instance.get_learn_dify_apps.return_value = { "recommended_apps": [app], "categories": ["Workflow"], } - monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) + mock_retrieval_factory = MagicMock(return_value=mock_retrieval_instance) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=mock_retrieval_factory), + ) monkeypatch.setattr( service_module.FeatureService, "get_system_features", diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 3e804583a6e..5b524cf9c64 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -94,7 +94,7 @@ class TestSystemFeatureApi: "controllers.console.feature.current_account_with_tenant_optional", return_value=(account, "tenant-123"), ) - system_features = SystemFeatureModel(is_allow_register=True) + system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True) get_system_features = mocker.patch( "controllers.console.feature.FeatureService.get_system_features", return_value=system_features, @@ -104,6 +104,7 @@ class TestSystemFeatureApi: result = api.get() assert result == system_features.model_dump() + assert result["enable_learn_app"] is True current_account.assert_called_once_with() get_system_features.assert_called_once_with(is_authenticated=True) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 28f416ac27f..4285fe088c6 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -29,6 +29,7 @@ from core.app.entities.queue_entities import ( QueueNodeExceptionEvent, QueueNodeFailedEvent, QueuePingEvent, + QueueReasoningChunkEvent, QueueRetrieverResourcesEvent, QueueStopEvent, QueueTextChunkEvent, @@ -46,6 +47,7 @@ from core.app.entities.task_entities import ( MessageAudioStreamResponse, MessageEndStreamResponse, PingStreamResponse, + ReasoningChunkStreamResponse, ) from core.base.tts.app_generator_tts_publisher import AudioTrunk from core.workflow.system_variables import build_system_variables @@ -196,6 +198,42 @@ class TestAdvancedChatGenerateTaskPipeline: assert pipeline._task_state.answer == "hi" assert responses + def test_handle_reasoning_chunk_event_emits_on_nonempty(self): + pipeline = _make_pipeline() + event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False) + + responses = list(pipeline._handle_reasoning_chunk_event(event)) + + assert len(responses) == 1 + response = responses[0] + assert isinstance(response, ReasoningChunkStreamResponse) + assert response.data.message_id == pipeline._message_id + assert response.data.reasoning == "pondering" + assert response.data.node_id == "llm-1" + assert response.data.is_final is False + # reasoning never touches the answer stream + assert pipeline._task_state.answer == "" + + def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self): + pipeline = _make_pipeline() + event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False) + + responses = list(pipeline._handle_reasoning_chunk_event(event)) + + assert responses == [] + + def test_handle_reasoning_chunk_event_emits_empty_final_marker(self): + pipeline = _make_pipeline() + event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True) + + responses = list(pipeline._handle_reasoning_chunk_event(event)) + + assert len(responses) == 1 + response = responses[0] + assert isinstance(response, ReasoningChunkStreamResponse) + assert response.data.reasoning == "" + assert response.data.is_final is True + def test_listen_audio_msg_returns_audio_stream(self): pipeline = _make_pipeline() publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data")) @@ -319,6 +357,43 @@ class TestAdvancedChatGenerateTaskPipeline: assert responses == ["done"] assert pipeline._recorded_files + def test_handle_node_succeeded_event_records_llm_reasoning(self): + pipeline = _make_pipeline() + pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: [] + pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" + pipeline._save_output_for_event = lambda event, node_execution_id: None + + event = SimpleNamespace( + node_type=BuiltinNodeTypes.LLM, + outputs={"reasoning_content": "first pass "}, + node_execution_id="exec", + node_id="llm-1", + ) + + list(pipeline._handle_node_succeeded_event(event)) + + assert pipeline._task_state.metadata.reasoning == {"llm-1": "first pass "} + + def test_handle_node_succeeded_event_accumulates_reasoning_across_passes(self): + pipeline = _make_pipeline() + pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: [] + pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" + pipeline._save_output_for_event = lambda event, node_execution_id: None + + def _llm_event(reasoning: str): + return SimpleNamespace( + node_type=BuiltinNodeTypes.LLM, + outputs={"reasoning_content": reasoning}, + node_execution_id="exec", + node_id="llm-1", + ) + + # Same node id across iteration/loop passes must accumulate, not overwrite. + list(pipeline._handle_node_succeeded_event(_llm_event("pass one "))) + list(pipeline._handle_node_succeeded_event(_llm_event("pass two"))) + + assert pipeline._task_state.metadata.reasoning == {"llm-1": "pass one pass two"} + def test_iteration_and_loop_handlers(self): pipeline = _make_pipeline() pipeline._workflow_run_id = "run-id" diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index c463c155a52..69ed5919d27 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -16,6 +16,7 @@ from core.app.entities.queue_entities import ( QueueNodeFailedEvent, QueueNodeRetryEvent, QueueNodeSucceededEvent, + QueueReasoningChunkEvent, QueueTextChunkEvent, QueueWorkflowPausedEvent, QueueWorkflowStartedEvent, @@ -34,6 +35,7 @@ from graphon.graph_events import ( NodeRunHumanInputFormFilledEvent, NodeRunIterationSucceededEvent, NodeRunLoopFailedEvent, + NodeRunReasoningChunkEvent, NodeRunRetryEvent, NodeRunStartedEvent, NodeRunStreamChunkEvent, @@ -395,6 +397,17 @@ class TestWorkflowBasedAppRunner: is_final=False, ), ) + runner._handle_event( + workflow_entry, + NodeRunReasoningChunkEvent( + id="exec", + node_id="node", + node_type=BuiltinNodeTypes.LLM, + selector=["node", "reasoning_content"], + chunk="thinking", + is_final=False, + ), + ) runner._handle_event( workflow_entry, NodeRunAgentLogEvent( @@ -442,6 +455,7 @@ class TestWorkflowBasedAppRunner: ) assert any(isinstance(event, QueueTextChunkEvent) for event in published) + assert any(isinstance(event, QueueReasoningChunkEvent) for event in published) assert any(isinstance(event, QueueAgentLogEvent) for event in published) assert any(isinstance(event, QueueIterationCompletedEvent) for event in published) assert any(isinstance(event, QueueLoopCompletedEvent) for event in published) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 0aaee900e37..9a04014d620 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -26,6 +26,7 @@ from core.app.entities.queue_entities import ( QueueNodeStartedEvent, QueueNodeSucceededEvent, QueuePingEvent, + QueueReasoningChunkEvent, QueueStopEvent, QueueTextChunkEvent, QueueWorkflowFailedEvent, @@ -40,6 +41,7 @@ from core.app.entities.task_entities import ( MessageAudioEndStreamResponse, MessageAudioStreamResponse, PingStreamResponse, + ReasoningChunkStreamResponse, WorkflowAppPausedBlockingResponse, WorkflowFinishStreamResponse, WorkflowStartStreamResponse, @@ -265,6 +267,41 @@ class TestWorkflowGenerateTaskPipeline: assert responses[0].data.text == "hi" assert published == [queue_message] + def test_handle_reasoning_chunk_event_emits_on_nonempty(self): + pipeline = _make_pipeline() + event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False) + + responses = list(pipeline._handle_reasoning_chunk_event(event)) + + assert len(responses) == 1 + response = responses[0] + assert isinstance(response, ReasoningChunkStreamResponse) + # workflow runs have no message, so the id is omitted + assert response.data.message_id is None + assert response.data.reasoning == "pondering" + assert response.data.node_id == "llm-1" + assert response.data.is_final is False + + def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self): + pipeline = _make_pipeline() + event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False) + + responses = list(pipeline._handle_reasoning_chunk_event(event)) + + assert responses == [] + + def test_handle_reasoning_chunk_event_emits_empty_final_marker(self): + pipeline = _make_pipeline() + event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True) + + responses = list(pipeline._handle_reasoning_chunk_event(event)) + + assert len(responses) == 1 + response = responses[0] + assert isinstance(response, ReasoningChunkStreamResponse) + assert response.data.reasoning == "" + assert response.data.is_final is True + def test_dispatch_event_handles_node_failed(self): pipeline = _make_pipeline() pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" diff --git a/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py index f5f27f7296f..e8fcbaf96ad 100644 --- a/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py +++ b/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py @@ -42,6 +42,16 @@ class TestBuildInRecommendAppRetrieval: mock_fetch.assert_called_once_with("en-US") assert result == {"apps": []} + @patch("services.recommend_app.buildin.buildin_retrieval.DatabaseRecommendAppRetrieval") + def test_get_learn_dify_apps_delegates_to_database(self, mock_database_retrieval): + expected = {"recommended_apps": [{"id": "learn-dify-app"}]} + mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = expected + + result = BuildInRecommendAppRetrieval().get_learn_dify_apps("en-US") + + assert result == expected + mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US") + def test_get_recommend_app_detail_delegates(self): with patch.object( BuildInRecommendAppRetrieval, diff --git a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py index c7b86e5743d..55165deec25 100644 --- a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py +++ b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from services.recommend_app.recommend_app_type import RecommendAppType from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval @@ -58,6 +59,32 @@ class TestRemoteRecommendAppRetrieval: result = RemoteRecommendAppRetrieval().get_recommended_apps_and_categories("en-US") assert result == {"recommended_apps": [{"id": "builtin"}]} + @patch.object( + RemoteRecommendAppRetrieval, + "fetch_learn_dify_apps_from_dify_official", + return_value={"recommended_apps": [{"id": "learn-dify-app"}]}, + ) + def test_get_learn_dify_apps_success(self, mock_fetch): + result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US") + + assert result == {"recommended_apps": [{"id": "learn-dify-app"}]} + mock_fetch.assert_called_once_with("en-US") + + @patch( + "services.recommend_app.remote.remote_retrieval.DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db", + return_value={"recommended_apps": [{"id": "db-fallback"}]}, + ) + @patch.object( + RemoteRecommendAppRetrieval, + "fetch_learn_dify_apps_from_dify_official", + side_effect=ValueError("server error"), + ) + def test_get_learn_dify_apps_falls_back_to_database_on_error(self, mock_fetch, mock_database): + result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US") + + assert result == {"recommended_apps": [{"id": "db-fallback"}]} + mock_database.assert_called_once_with("en-US") + class TestFetchFromDifyOfficial: @patch("services.recommend_app.remote.remote_retrieval.dify_config") @@ -118,3 +145,84 @@ class TestFetchFromDifyOfficial: result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") assert "categories" not in result + assert mock_get.call_args.kwargs["headers"] == {} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_forwards_request_origin_header(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "https://saas.dify.dev" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + flask_app = Flask(__name__) + with flask_app.test_request_context(headers={"Origin": "https://cloud.example.com"}): + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://cloud.example.com"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_falls_back_to_console_web_url_origin(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "https://saas.dify.dev/console" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + flask_app = Flask(__name__) + with flask_app.test_request_context(): + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://saas.dify.dev/console"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_falls_back_to_console_web_url_without_request_context(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "http://localhost:3000/console" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "http://localhost:3000/console"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_uses_console_web_url_without_scheme(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "saas.dify.dev" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + flask_app = Flask(__name__) + with flask_app.test_request_context(): + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "saas.dify.dev"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_learn_dify_apps_returns_json_on_200(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": [{"id": "learn-dify-app"}]} + mock_get.return_value = mock_response + + result = RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US") + + assert result == {"recommended_apps": [{"id": "learn-dify-app"}]} + assert mock_get.call_args.args[0] == "https://example.com/apps/learn-dify?language=en-US" + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_learn_dify_apps_raises_on_non_200(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_get.return_value = MagicMock(status_code=500) + + with pytest.raises(ValueError, match="fetch learn dify apps failed"): + RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US") diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_feature_service_learn_app.py new file mode 100644 index 00000000000..ed64c4d08dc --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_learn_app.py @@ -0,0 +1,17 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, SystemFeatureModel + + +def test_system_feature_model_defaults_enable_learn_app(): + assert SystemFeatureModel().enable_learn_app is True + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPatch, enabled: bool): + monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LEARN_APP", enabled) + + result = FeatureService.get_system_features() + + assert result.enable_learn_app is enabled diff --git a/api/uv.lock b/api/uv.lock index 6ad4fa5cff1..097b38d3388 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -51,6 +51,7 @@ members = [ "dify-vdb-weaviate", ] overrides = [ + { name = "cryptography", specifier = ">=49.0.0,<50.0.0" }, { name = "litellm", specifier = ">=1.83.10,<2.0.0" }, { name = "pyarrow", specifier = ">=23.0.1,<24.0.0" }, ] @@ -87,7 +88,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.4" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -96,27 +97,29 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, - { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, - { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, - { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, ] [[package]] @@ -556,14 +559,14 @@ wheels = [ [[package]] name = "bleach" -version = "6.3.0" +version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, ] [[package]] @@ -1141,41 +1144,39 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] [[package]] @@ -1869,7 +1870,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "langsmith", specifier = "==0.8.5" }] +requires-dist = [{ name = "langsmith", specifier = "==0.8.18" }] [[package]] name = "dify-trace-mlflow" @@ -3663,7 +3664,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.8.5" +version = "0.8.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3673,12 +3674,13 @@ dependencies = [ { name = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, + { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/eb/8883d1158c743d0aac350f09df7880714d27283497e8c80bb9fe3480f165/langsmith-0.8.5.tar.gz", hash = "sha256:3615243d99c12f4047f13042bdc05a373dce232d106a6511b3ca7b48c5af1c2c", size = 4462348, upload-time = "2026-05-15T21:31:41.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/85/968c88a63e32a59b3e5c68afd2fe114ce0708a125db0be1a85efc25fb2ea/langsmith-0.8.5-py3-none-any.whl", hash = "sha256:efc779f9d450dcaf9d97bc8894f4926276509d6e730e05289af9a64debce06ae", size = 399564, upload-time = "2026-05-15T21:31:39.046Z" }, + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, ] [[package]] @@ -3713,7 +3715,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.14" +version = "1.89.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3729,9 +3731,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/f1/f7cfead063f2ab1877c8fb465d0d7fe300b75f081bcb73525f6d550aeb1c/litellm-1.89.3.tar.gz", hash = "sha256:8fcdb2b7a0ef3381d41adf164443842e31ef9f0cd5bcda6fc3c0bd8bc2959510", size = 14080611, upload-time = "2026-06-20T22:42:26.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f1/34d174ff1d84e459b30f971606ac9cb7078ad24cd7661e9786b25adf7def/litellm-1.89.3-py3-none-any.whl", hash = "sha256:414ef5aee504b2b3eb1b219d39f1c11902db399cbdbc06e5fb550c15d731abeb", size = 15495226, upload-time = "2026-06-20T22:42:23.156Z" }, ] [[package]] @@ -5225,16 +5227,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -5353,11 +5355,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.10.2" +version = "6.14.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" }, ] [[package]] @@ -6237,15 +6239,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -7053,24 +7055,29 @@ wheels = [ [[package]] name = "ujson" -version = "5.12.1" +version = "5.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/78/937198ea8708182dd1edbf0237bf255a96feab3f511691ad08b84da98e5d/ujson-5.12.1.tar.gz", hash = "sha256:5b7e96406c301a1366534479a7352ec40ec68bb327c0c119091635acd5925e35", size = 7164538, upload-time = "2026-05-05T22:05:01.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/7a/c8bb37c8f6f3623d60c33d15d18cd6d6655d0f9c3eb31a9969f76361b199/ujson-5.13.0.tar.gz", hash = "sha256:d62e3d7625384c08082abad81a077af587fdef2761bb14c3822f4234b8d07d75", size = 7166784, upload-time = "2026-06-14T22:36:50.209Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/40/dbb8e2fe6ee33769602fba203dacaa3963b6599f0d0aefdf2b8811af5f70/ujson-5.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:10f44bd08ae52ee23ca6e8b472692e5da1768af2d53ff1bad6f40b532e0bc7ee", size = 57951, upload-time = "2026-05-05T22:03:31.606Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/627472e6b4ac34148ea52e6d3d15f6f366fc21c72fe7d6c7d3729d4b3ac5/ujson-5.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cc6ea753b7303fa5629fa9ac9257ea4b001c4d72583b2bb36ff1855a07db49f", size = 55562, upload-time = "2026-05-05T22:03:32.853Z" }, - { url = "https://files.pythonhosted.org/packages/be/59/1248c966da197ae7d2673542444a2d9a1ff7c46e3ec2a302c3caf902b922/ujson-5.12.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:289f13095764d03734adfa10107da9b530ceb64dc1b02a5f507588d978d5b7df", size = 59448, upload-time = "2026-05-05T22:03:34.143Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d7/60c1ca71a09c0654c3edca1192a18fc55e6cc06107be86d7d3f2b39fb29b/ujson-5.12.1-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:427893168d074e59214b0ee058337c57f5bb80175cdd5b4799a9c931aae22022", size = 61608, upload-time = "2026-05-05T22:03:35.386Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0a/c619525576219bfc50084100117481b1a732a16716a3878355570995de4e/ujson-5.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7a81724d5d90a2da7155d15d8b156ce57eaed7cdd622df813f36a8e612fd4c8", size = 59113, upload-time = "2026-05-05T22:03:37.555Z" }, - { url = "https://files.pythonhosted.org/packages/18/4d/79c1674036085e8dfdb77f8d87c1fd2896e97e6affd117c5e8ecc40f0ae4/ujson-5.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a6efff7dc6515416366819de4a1bc449b77107c5b48508b101fd40f7f8bec08", size = 1038914, upload-time = "2026-05-05T22:03:38.954Z" }, - { url = "https://files.pythonhosted.org/packages/94/b1/9409bba17189ee282b6314cdf0ecdcc72e3d38cd565c870c0227d0494569/ujson-5.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77a71fe53427a0cf49d56eafd801d9f7e203b784b7f99cc717783fd6f6f7b732", size = 1198408, upload-time = "2026-05-05T22:03:40.943Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ad/fafbce7ac59f1a10a83892d0a34add23cc06492308e1330493aab707dc20/ujson-5.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea3bed53d2ea8e5642e814a9e41f3e29420a8067874ba03ace8c0462e160490c", size = 1091451, upload-time = "2026-05-05T22:03:42.739Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1f/76fc9d5b1dcb9eb73ed45fd56e5114391bd30808eb1cea7f8bc5c9a64324/ujson-5.12.1-cp312-cp312-win32.whl", hash = "sha256:758e5c8fbe4e6d483041e03b307b01fb5d2f2dd4452d4d4b927ab902e188939e", size = 41049, upload-time = "2026-05-05T22:03:44.341Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/7ce3b6fda10d05b79a245db03405734b521ba3da6c377f173b018dce6d4e/ujson-5.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:f6074d3d3267ba1914c624b6e1fa3d8152648ff36b0ab77ddf83b92db488c30d", size = 45330, upload-time = "2026-05-05T22:03:45.828Z" }, - { url = "https://files.pythonhosted.org/packages/d7/66/5a37bba7a2e2ab36ae467521c4511e6593ad74c869f62ec4ba6330f3f71e/ujson-5.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:7642a41520ac1b2bc25ea282b66b8da522cc43424442e6fb5e039be4d4f96530", size = 39828, upload-time = "2026-05-05T22:03:47.123Z" }, - { url = "https://files.pythonhosted.org/packages/6d/26/c9d0479236b3f5690d6a8bb45f708aabc2c91ca80d275eba24b1e9e464ab/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c419bf42ae40963fc27f70c59e24e9a97f5cf168dbce2c572f3c0ce3595912", size = 56153, upload-time = "2026-05-05T22:04:40.326Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c8/785f4e132500aff2f1fd2bd4a4b86fe396a5519f830a098358c90ebb92ee/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0be2b4f2f547b9f0f3d902640e410e5a2fc851576cbe033c88445a23e3e7aef1", size = 57352, upload-time = "2026-05-05T22:04:42.005Z" }, - { url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988, upload-time = "2026-05-05T22:04:43.36Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/b66deca15da1f7faf6952d8eddf55978482bcbfd294ed2afe2c526ea325f/ujson-5.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf81570ac056cb058f9117b52ca5dd800bfe9381d0076d0bb30a08a54591d654", size = 56743, upload-time = "2026-06-14T22:35:28.863Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/b03bcc9eaf4621ac9008dec90918d8fb4839d611666cb99eb255696c67fe/ujson-5.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7edf16359c52ed53406e216565d83e6b98c23c3cb9a0a01673f2493f8fb15edf", size = 54390, upload-time = "2026-06-14T22:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/f98c6c1a4ed9d92d39d5d2d133f2b6fce5da11ea50c341117aedde8011c4/ujson-5.13.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:24539618fb3243cfdf27dab9a850acab80798a01501e9586b61fb9ecd016a891", size = 60047, upload-time = "2026-06-14T22:35:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/f68e14cf476d149945211142f4c20782c1f232c489e8edcc4f4b58ce4997/ujson-5.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fdde6341d213b29f413b5fa9fad1392d5408074c75f0900ed949e97e546fa5df", size = 53437, upload-time = "2026-06-14T22:35:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1a/5718237cf4141e5be46ff371387e90b01f27774cb6f0f79ff4803a2430ca/ujson-5.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:229faf041ef249ee3fd57bac1cedb123d2718ab63f6ccd50eca95ea902eb0dca", size = 55057, upload-time = "2026-06-14T22:35:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6f/7f55c1e9e0be87beebaed553fa186ad5f6d5d639cbaa9d49f78f2f91c3a9/ujson-5.13.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d02f31c2f59cc6a1c2c3633b377701fc2d8e876cc01950735d7a01132ccc233", size = 58186, upload-time = "2026-06-14T22:35:34.055Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c4/9a34ade542426f56a0bc042f774073d1c247ae7575363c27587788cb2b2f/ujson-5.13.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea7204e9fa7538bfbb1396e1ee8c2bbcd3818b3633ef5bb14d4fdea52994d14d", size = 57935, upload-time = "2026-06-14T22:35:35.05Z" }, + { url = "https://files.pythonhosted.org/packages/36/06/407633f0709e168107f56368bd5a0fa8fe07acd7f1d3000710bc0bb07470/ujson-5.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c5a2478a3a1fa4421f7e035b87194eea0cf44c7971a3f32ad1b42a0dfd63c03", size = 1037685, upload-time = "2026-06-14T22:35:36.022Z" }, + { url = "https://files.pythonhosted.org/packages/c3/df/eb5bd92dc1b23254fea5b2022007baff5491a7478bfcf7e9260d3a10f1ac/ujson-5.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b535e0970c96957e999cfe5ec89361f0e8d0bb987fb5d5144f6f495cb3ed9e19", size = 1197141, upload-time = "2026-06-14T22:35:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1c/65f2ce1a0411ec9a87339db01f0d5d554a49c4248ec68ab52a1b7e14e9c4/ujson-5.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d0ad1207694988498fca7e0bb28eba7564fa33261d2f9fdf66a3aaab376b803", size = 1090225, upload-time = "2026-06-14T22:35:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/73/53/310aabff0704f9c7ef0d3f431ce8b8e3147c3cca25334a205615c511f65e/ujson-5.13.0-cp312-cp312-win32.whl", hash = "sha256:d6bc9fa43a49e403c68c7eb164eef0feee9dd29474a7c6e0d3b6267025371990", size = 40075, upload-time = "2026-06-14T22:35:40.44Z" }, + { url = "https://files.pythonhosted.org/packages/b5/23/d3536d8945d1bb00248d998c8dcbe678a884681ad181072daecfafe4eea6/ujson-5.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6692d49ff970aaa5008f4a6fe06974bc91fd957bf13173f765e46d8ba44906ea", size = 41097, upload-time = "2026-06-14T22:35:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/72/a1/4b147c06ee5bb14bec6e26786358c8510c4d75e28b88146a6ac7620f1f71/ujson-5.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:5737ffe0740a788b0e6255f0ffb281db49305fd6e6a587be44c73d9e92b554c4", size = 38875, upload-time = "2026-06-14T22:35:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/30/70/dbdd277d64bd3a149532567ceb082fe26f4ead58c39e0a97566ccbdf14a3/ujson-5.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3e074a1f7778d58aa3b3056bab7b6251aabb3f381808018ca2b7fb8dbdeef7ab", size = 58393, upload-time = "2026-06-14T22:36:28.702Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/592c70af94a67cafacd9c840ae2980f27d511dde2732a4c0dfac8f176ae8/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bb53ef95d35875262b8d0aa28506ca612ddd07058bee2a90f609938e69dc801", size = 54447, upload-time = "2026-06-14T22:36:29.802Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9d/2bb91e1e25a8584cb3b63544b9bd26f621173535c77ac6cae13bad8e7904/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb296a0aa480ab88d895ddaa90372604c08ccc72323f02590612c775426ab413", size = 56066, upload-time = "2026-06-14T22:36:30.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/ec/8e3802fc4a4e31e817b972bbb0e704a484d8c75ec349b3feb45fa9fb54c4/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2862f81af44b3a7e74c5d80caa118d736be1991ce6f1d5c723716fa403060cc6", size = 54938, upload-time = "2026-06-14T22:36:32.051Z" }, + { url = "https://files.pythonhosted.org/packages/4a/48/d0e3e511039b86fd1ecfe2bf761c800552d273ef8f19e71de93bf38a909e/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c16e07581172f08585b409246f4535dab13ee85af0e3d3cfa8684b653ca44fa8", size = 56115, upload-time = "2026-06-14T22:36:33.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/689613037fe691d18eae075cd141089f3a3156146be14512df92d8a9ae8f/ujson-5.13.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:9bd0f2dd05937c3b089af316884de18c6f6182ddb8ffce597d2e7c7a9ba9f447", size = 41802, upload-time = "2026-06-14T22:36:34.523Z" }, ] [[package]] diff --git a/cli/src/commands/resume/app/index.ts b/cli/src/commands/resume/app/index.ts index 596654181b5..c4bbfbf2f56 100644 --- a/cli/src/commands/resume/app/index.ts +++ b/cli/src/commands/resume/app/index.ts @@ -29,7 +29,7 @@ export default class ResumeApp extends DifyCommand { 'workspace': Flags.string({ description: 'workspace id override' }), 'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }), 'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }), - 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips ... blocks silently by default; with --think, thinking is printed to stderr.', default: false }), + 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }), 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }), 'http-retry': httpRetryFlag, } diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts index 3ed602a3410..de39bfda2e5 100644 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -7,6 +7,7 @@ import { collect, HitlPauseError } from '@/commands/run/app/sse-collector' import { formatted, stringifyOutput } from '@/framework/output' import { handle, unhandle } from '@/sys/index' import { colorEnabled, colorScheme } from '@/sys/io/color' +import { reasoningBlocksFromMetadata } from '@/sys/io/reasoning' import { startSpinner } from '@/sys/io/spinner' import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks } from '@/sys/io/think-filter' @@ -99,6 +100,13 @@ export class StreamingStructuredStrategy implements RunStrategy { } } + // Surface separated-mode reasoning (carried in message_end metadata) to stderr under --think. + if (ctx.think) { + const reasoningBlocks = reasoningBlocksFromMetadata(processedResp.metadata) + if (reasoningBlocks !== '') + deps.io.err.write(`${reasoningBlocks}\n`) + } + const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode deps.io.out.write(stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) }))) if (isText && CHAT_MODES.has(respMode)) { diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts index 815d708d9d8..6b63e17e81e 100644 --- a/cli/src/commands/run/app/index.ts +++ b/cli/src/commands/run/app/index.ts @@ -35,7 +35,7 @@ export default class RunApp extends DifyCommand { 'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }), 'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), 'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }), - 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips ... blocks silently by default; with --think, thinking is printed to stderr.', default: false }), + 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }), 'retry-on-limit': Flags.boolean({ description: 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', default: false }), 'http-retry': httpRetryFlag, 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }), diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index 57b02aeb47d..a4e201c8ee1 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -203,6 +203,85 @@ describe('runApp', () => { expect(io.errBuf()).toContain('secret reasoning') }) + it('--stream chat --think: routes separated reasoning to stderr, clean answer to stdout', async () => { + mock.setScenario('chat-reasoning') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-1', message: 'hi', stream: true, think: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.outBuf()).toContain('final answer') + expect(io.outBuf()).not.toContain('secret reasoning') + expect(io.errBuf()).toContain('') + expect(io.errBuf()).toContain('secret reasoning') + }) + + it('--stream chat without --think: separated reasoning stays hidden', async () => { + mock.setScenario('chat-reasoning') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-1', message: 'hi', stream: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.outBuf()).toContain('final answer') + expect(io.errBuf()).not.toContain('secret reasoning') + }) + + it('chat -o json --think: echoes separated reasoning to stderr, persists it in metadata', async () => { + mock.setScenario('chat-reasoning') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-1', message: 'hi', format: 'json', think: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.errBuf()).toContain('secret reasoning') + const parsed = JSON.parse(io.outBuf()) as { answer: string, metadata: { reasoning: Record } } + expect(parsed.answer).toBe('final answer') + expect(parsed.metadata.reasoning).toEqual({ 'llm-1': 'secret reasoning' }) + }) + + it('--stream workflow --think: routes separated reasoning to stderr, clean outputs to stdout', async () => { + mock.setScenario('workflow-reasoning') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-2', inputs: { x: '1' }, stream: true, think: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.errBuf()).toContain('') + expect(io.errBuf()).toContain('secret reasoning') + expect(io.outBuf()).toContain('final answer') + expect(io.outBuf()).not.toContain('secret reasoning') + }) + + it('workflow -o json --think: echoes reasoning to stderr, accumulates into metadata.reasoning', async () => { + mock.setScenario('workflow-reasoning') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-2', inputs: { x: '1' }, format: 'json', think: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.errBuf()).toContain('secret reasoning') + const parsed = JSON.parse(io.outBuf()) as { metadata: { reasoning: Record } } + expect(parsed.metadata.reasoning).toEqual({ 'llm-1': 'secret reasoning' }) + }) + + it('--stream workflow without --think: reasoning stays hidden', async () => { + mock.setScenario('workflow-reasoning') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-2', inputs: { x: '1' }, stream: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.outBuf()).toContain('final answer') + expect(io.errBuf()).not.toContain('secret reasoning') + }) + it('stream-error scenario: error event surfaces typed BaseError', async () => { mock.setScenario('stream-error') const io = bufferStreams() diff --git a/cli/src/commands/run/app/sse-collector.test.ts b/cli/src/commands/run/app/sse-collector.test.ts index e92d3694d3f..d26abb5608b 100644 --- a/cli/src/commands/run/app/sse-collector.test.ts +++ b/cli/src/commands/run/app/sse-collector.test.ts @@ -59,6 +59,41 @@ describe('collect — chat', () => { }) }) +describe('collect — chat separated reasoning', () => { + function reasoningEvent(reasoning: string, isFinal: boolean) { + return ev('reasoning_chunk', { data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal } }) + } + + it('backfills metadata.reasoning from live deltas when the server omits it', async () => { + const got = await collect(iterOf( + reasoningEvent('pon', false), + reasoningEvent('dering', true), + ev('message', { message_id: 'm1', answer: 'answer' }), + ev('message_end', { metadata: { usage: { tokens: 3 } } }), + ), 'advanced-chat') + expect(got.answer).toBe('answer') + expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' }) + expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 3 }) + }) + + it('keeps the server-persisted reasoning over live deltas', async () => { + const got = await collect(iterOf( + reasoningEvent('live', true), + ev('message', { answer: 'a' }), + ev('message_end', { metadata: { reasoning: { 'llm-1': 'persisted' } } }), + ), 'advanced-chat') + expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'persisted' }) + }) + + it('leaves metadata untouched when there is no reasoning at all', async () => { + const got = await collect(iterOf( + ev('message', { answer: 'a' }), + ev('message_end', { metadata: { usage: { tokens: 1 } } }), + ), 'advanced-chat') + expect((got.metadata as { reasoning?: unknown }).reasoning).toBeUndefined() + }) +}) + describe('collect — agent-chat', () => { it('captures agent_thoughts', async () => { const got = await collect(iterOf( @@ -97,6 +132,39 @@ describe('collect — workflow', () => { }) }) +describe('collect — workflow separated reasoning', () => { + function wfReasoning(reasoning: string, nodeId: string, isFinal: boolean) { + return ev('reasoning_chunk', { data: { reasoning, node_id: nodeId, is_final: isFinal } }) + } + + it('accumulates reasoning_chunk per node into metadata.reasoning', async () => { + const got = await collect(iterOf( + ev('node_started', { id: 'llm-1' }), + wfReasoning('pon', 'llm-1', false), + wfReasoning('dering', 'llm-1', true), + ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), + ), 'workflow') + expect((got.data as { outputs: { result: string } }).outputs.result).toBe('clean') + expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' }) + }) + + it('keys reasoning by node, leaves metadata absent when there is none', async () => { + const got = await collect(iterOf( + ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), + ), 'workflow') + expect((got.metadata as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined() + }) + + it('merges reasoning into metadata already carried by workflow_finished', async () => { + const got = await collect(iterOf( + wfReasoning('think', 'llm-1', true), + ev('workflow_finished', { data: { status: 'succeeded' }, metadata: { usage: { tokens: 7 } } }), + ), 'workflow') + expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'think' }) + expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 7 }) + }) +}) + describe('collect — error event', () => { it('throws BaseError when error event arrives', async () => { await expect(collect(iterOf( diff --git a/cli/src/commands/run/app/sse-collector.ts b/cli/src/commands/run/app/sse-collector.ts index 043a9690d5f..b3b3a33b06c 100644 --- a/cli/src/commands/run/app/sse-collector.ts +++ b/cli/src/commands/run/app/sse-collector.ts @@ -2,6 +2,7 @@ import type { BaseError } from '@/errors/base' import type { SseEvent } from '@/http/sse' import { HttpClientError, newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' +import { accumulateReasoning, parseReasoningChunk } from '@/sys/io/reasoning' import { RUN_MODES } from './handlers' export type HitlPauseData = { @@ -67,6 +68,7 @@ class ChatCollector implements Collector { private base: Record = {} private metadata: Record | undefined private thoughts: unknown[] = [] + private readonly reasoning: Record = {} private readonly mode: string private readonly isAgent: boolean constructor(mode: string, isAgent: boolean) { @@ -84,6 +86,13 @@ class ChatCollector implements Collector { copyScalar(this.base, c, ['id', 'conversation_id', 'message_id', 'task_id', 'created_at']) return } + // Accumulate separated-mode reasoning deltas per LLM node. + case 'reasoning_chunk': { + const chunk = parseReasoningChunk(c) + if (chunk !== undefined) + accumulateReasoning(this.reasoning, chunk) + return + } case 'agent_thought': this.thoughts.push(c) return @@ -98,12 +107,23 @@ class ChatCollector implements Collector { const out: Record = { mode: this.mode, answer: this.answer, ...this.base } if (this.metadata !== undefined) out.metadata = this.metadata + // Fall back to live deltas only when the server didn't persist reasoning in metadata. + if (Object.keys(this.reasoning).length > 0 && !hasReasoning(this.metadata)) + out.metadata = { ...(this.metadata ?? {}), reasoning: this.reasoning } if (this.isAgent || this.thoughts.length > 0) out.agent_thoughts = this.thoughts return out } } +function hasReasoning(metadata: Record | undefined): boolean { + const reasoning = metadata?.reasoning + return reasoning !== null + && typeof reasoning === 'object' + && !Array.isArray(reasoning) + && Object.keys(reasoning as object).length > 0 +} + class CompletionCollector implements Collector { private answer = '' private base: Record = {} @@ -133,14 +153,29 @@ class CompletionCollector implements Collector { class WorkflowCollector implements Collector { private final: Record | undefined + private readonly reasoning: Record = {} consume(ev: SseEvent): void { + if (ev.name === 'reasoning_chunk') { + const chunk = parseReasoningChunk(parseJson(ev.data)) + if (chunk !== undefined) + accumulateReasoning(this.reasoning, chunk) + return + } if (ev.name !== 'workflow_finished') return this.final = parseJson(ev.data) } finalize(): Record { - return { mode: RUN_MODES.Workflow, ...(this.final ?? {}) } + const out: Record = { mode: RUN_MODES.Workflow, ...(this.final ?? {}) } + // Workflow runs don't persist reasoning; surface live deltas under metadata.reasoning. + if (Object.keys(this.reasoning).length > 0) { + const existing = (out.metadata !== null && typeof out.metadata === 'object' && !Array.isArray(out.metadata)) + ? out.metadata as Record + : undefined + out.metadata = { ...(existing ?? {}), reasoning: this.reasoning } + } + return out } } diff --git a/cli/src/commands/run/app/stream-handlers.test.ts b/cli/src/commands/run/app/stream-handlers.test.ts index bab45144a01..ec9cd957bb5 100644 --- a/cli/src/commands/run/app/stream-handlers.test.ts +++ b/cli/src/commands/run/app/stream-handlers.test.ts @@ -37,6 +37,42 @@ describe('streamPrinterFor — chat', () => { }) }) +function reasoningEvent(reasoning: string, isFinal: boolean) { + return ev('reasoning_chunk', { data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal } }) +} + +describe('streamPrinterFor — chat separated reasoning', () => { + it('think: true frames reasoning_chunk deltas to stderr, answer stays clean on stdout', () => { + const sp = streamPrinterFor('advanced-chat', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, reasoningEvent('pon', false)) + sp.onEvent(cap.out, cap.err, reasoningEvent('dering', false)) + sp.onEvent(cap.out, cap.err, reasoningEvent('', true)) + sp.onEvent(cap.out, cap.err, ev('message', { conversation_id: 'c1', answer: 'final answer' })) + sp.onEnd(cap.out, cap.err) + expect(cap.errBuf()).toContain(' [llm-1]\npondering') + expect(cap.outBuf()).toBe('final answer\n') + }) + + it('think: false ignores reasoning_chunk entirely', () => { + const sp = streamPrinterFor('advanced-chat', false) + const cap = captures() + sp.onEvent(cap.out, cap.err, reasoningEvent('secret', true)) + sp.onEvent(cap.out, cap.err, ev('message', { answer: 'hi' })) + sp.onEnd(cap.out, cap.err) + expect(cap.errBuf()).not.toContain('secret') + expect(cap.outBuf()).toBe('hi\n') + }) + + it('closes an unterminated reasoning block on stream end', () => { + const sp = streamPrinterFor('advanced-chat', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, reasoningEvent('thinking', false)) + sp.onEnd(cap.out, cap.err) + expect(cap.errBuf()).toContain(' [llm-1]\nthinking') + }) +}) + describe('streamPrinterFor — agent-chat', () => { it('writes agent_thought to stderr', () => { const sp = streamPrinterFor('agent-chat') @@ -105,6 +141,62 @@ describe('streamPrinterFor — workflow think filtering', () => { }) }) +// Workflow reasoning_chunk events carry no message_id (workflow runs have no message). +function wfReasoning(reasoning: string, nodeId: string, isFinal: boolean) { + return ev('reasoning_chunk', { data: { reasoning, node_id: nodeId, is_final: isFinal } }) +} + +describe('streamPrinterFor — workflow separated reasoning', () => { + it('think: true frames reasoning_chunk to stderr, outputs stay clean on stdout', () => { + const sp = streamPrinterFor('workflow', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, ev('node_started', { id: 'llm-1', title: 'LLM' })) + sp.onEvent(cap.out, cap.err, wfReasoning('pon', 'llm-1', false)) + sp.onEvent(cap.out, cap.err, wfReasoning('dering', 'llm-1', false)) + sp.onEvent(cap.out, cap.err, wfReasoning('', 'llm-1', true)) + sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { result: 'clean answer' } } })) + sp.onEnd(cap.out, cap.err) + // node title precedes the reasoning block for attribution + expect(cap.errBuf()).toContain('→ LLM') + expect(cap.errBuf()).toContain(' [llm-1]\npondering') + const parsed = JSON.parse(cap.outBuf().trim()) as { result: string } + expect(parsed.result).toBe('clean answer') + }) + + it('think: false drops reasoning_chunk entirely', () => { + const sp = streamPrinterFor('workflow', false) + const cap = captures() + sp.onEvent(cap.out, cap.err, wfReasoning('secret', 'llm-1', true)) + sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { result: 'ok' } } })) + sp.onEnd(cap.out, cap.err) + expect(cap.errBuf()).not.toContain('secret') + const parsed = JSON.parse(cap.outBuf().trim()) as { result: string } + expect(parsed.result).toBe('ok') + }) + + it('closes an unterminated reasoning block on stream end', () => { + const sp = streamPrinterFor('workflow', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, wfReasoning('thinking', 'llm-1', false)) + sp.onEnd(cap.out, cap.err) + expect(cap.errBuf()).toContain(' [llm-1]\nthinking') + }) + + it('keeps interleaved parallel-node reasoning in separate node-tagged blocks', () => { + const sp = streamPrinterFor('workflow', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, wfReasoning('a1', 'llm-1', false)) + sp.onEvent(cap.out, cap.err, wfReasoning('b1', 'llm-2', false)) + sp.onEvent(cap.out, cap.err, wfReasoning('a2', 'llm-1', true)) + sp.onEvent(cap.out, cap.err, wfReasoning('b2', 'llm-2', true)) + sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { result: 'ok' } } })) + sp.onEnd(cap.out, cap.err) + expect(cap.errBuf()).toBe( + ' [llm-1]\na1\n [llm-2]\nb1\n [llm-1]\na2\n [llm-2]\nb2\n', + ) + }) +}) + describe('streamPrinterFor — unknown mode', () => { it('throws', () => { expect(() => streamPrinterFor('whatever')).toThrow() diff --git a/cli/src/commands/run/app/stream-handlers.ts b/cli/src/commands/run/app/stream-handlers.ts index 1955d5996bf..7ce96cf53c6 100644 --- a/cli/src/commands/run/app/stream-handlers.ts +++ b/cli/src/commands/run/app/stream-handlers.ts @@ -4,6 +4,7 @@ import type { SseEvent } from '@/http/sse' import { newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from '@/sys/io/color' +import { parseReasoningChunk, ReasoningChunkRenderer } from '@/sys/io/reasoning' import { filterThinkInOutputs, ThinkChunkFilter } from '@/sys/io/think-filter' import { RUN_MODES } from './handlers' import { HitlPauseError } from './sse-collector' @@ -43,9 +44,12 @@ function handleCommonEvents(ev: SseEvent): boolean { class ChatStreamPrinter implements StreamPrinter { private convoId = '' private readonly filter: ThinkChunkFilter + private readonly reasoning = new ReasoningChunkRenderer() + private readonly think: boolean private readonly isTTY: boolean constructor(think: boolean, isTTY = false) { this.filter = new ThinkChunkFilter(think) + this.think = think this.isTTY = isTTY } @@ -62,6 +66,15 @@ class ChatStreamPrinter implements StreamPrinter { this.convoId = c.conversation_id return } + // Stream separated-mode reasoning to stderr under --think. + case 'reasoning_chunk': { + if (!this.think) + return + const chunk = parseReasoningChunk(c) + if (chunk !== undefined) + this.reasoning.push(chunk, errOut) + return + } case 'agent_thought': if (typeof c.thought === 'string' && c.thought !== '') errOut.write(`thought: ${c.thought}\n`) @@ -73,6 +86,7 @@ class ChatStreamPrinter implements StreamPrinter { } onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { + this.reasoning.flush(errOut) this.filter.flush(out, errOut) out.write('\n') if (this.convoId !== '') { @@ -106,6 +120,7 @@ class CompletionStreamPrinter implements StreamPrinter { class WorkflowStreamPrinter implements StreamPrinter { private final: Record | undefined + private readonly reasoning = new ReasoningChunkRenderer() private readonly think: boolean constructor(think: boolean) { this.think = think @@ -124,6 +139,15 @@ class WorkflowStreamPrinter implements StreamPrinter { errOut.write(`→ ${title}\n`) return } + // Stream separated-mode reasoning to stderr under --think; the prior → title attributes the node. + case 'reasoning_chunk': { + if (!this.think) + return + const chunk = parseReasoningChunk(c) + if (chunk !== undefined) + this.reasoning.push(chunk, errOut) + return + } case 'node_finished': { const status = typeof c.status === 'string' ? c.status : '' if (status !== '' && status !== 'succeeded') { @@ -138,6 +162,7 @@ class WorkflowStreamPrinter implements StreamPrinter { } onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { + this.reasoning.flush(errOut) if (this.final === undefined) return const data = this.final.data diff --git a/cli/src/sys/io/reasoning.test.ts b/cli/src/sys/io/reasoning.test.ts new file mode 100644 index 00000000000..1f6887a6108 --- /dev/null +++ b/cli/src/sys/io/reasoning.test.ts @@ -0,0 +1,128 @@ +import { Buffer } from 'node:buffer' +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { + accumulateReasoning, + formatReasoningBlocks, + parseReasoningChunk, + reasoningBlocksFromMetadata, + ReasoningChunkRenderer, +} from './reasoning' + +function capture(): { err: PassThrough, errBuf: () => string } { + const err = new PassThrough() + const ec: Buffer[] = [] + err.on('data', d => ec.push(d as Buffer)) + return { err, errBuf: () => Buffer.concat(ec).toString('utf-8') } +} + +describe('parseReasoningChunk', () => { + it('reads the payload nested under data', () => { + expect(parseReasoningChunk({ data: { reasoning: 'hi', node_id: 'llm-1', is_final: true } })) + .toEqual({ reasoning: 'hi', nodeId: 'llm-1', isFinal: true }) + }) + + it('defaults missing/wrong-typed fields', () => { + expect(parseReasoningChunk({ data: {} })).toEqual({ reasoning: '', nodeId: '', isFinal: false }) + }) + + it('returns undefined when data is absent or not an object', () => { + expect(parseReasoningChunk({})).toBeUndefined() + expect(parseReasoningChunk({ data: null })).toBeUndefined() + expect(parseReasoningChunk({ data: ['x'] })).toBeUndefined() + }) +}) + +describe('ReasoningChunkRenderer', () => { + it('frames streamed deltas with a node-tagged open/close on the terminal marker', () => { + const cap = capture() + const r = new ReasoningChunkRenderer() + r.push({ reasoning: 'pon', nodeId: 'llm-1', isFinal: false }, cap.err) + r.push({ reasoning: 'dering', nodeId: 'llm-1', isFinal: false }, cap.err) + r.push({ reasoning: '', nodeId: 'llm-1', isFinal: true }, cap.err) + expect(cap.errBuf()).toBe(' [llm-1]\npondering\n') + }) + + it('emits separate node-tagged blocks per node', () => { + const cap = capture() + const r = new ReasoningChunkRenderer() + r.push({ reasoning: 'a', nodeId: 'n1', isFinal: true }, cap.err) + r.push({ reasoning: 'b', nodeId: 'n2', isFinal: true }, cap.err) + expect(cap.errBuf()).toBe(' [n1]\na\n [n2]\nb\n') + }) + + it('tags each block with its node id so interleaved fragments stay distinguishable', () => { + const cap = capture() + const r = new ReasoningChunkRenderer() + r.push({ reasoning: 'a1', nodeId: 'n1', isFinal: false }, cap.err) + r.push({ reasoning: 'b1', nodeId: 'n2', isFinal: false }, cap.err) + r.push({ reasoning: 'a2', nodeId: 'n1', isFinal: true }, cap.err) + r.push({ reasoning: 'b2', nodeId: 'n2', isFinal: true }, cap.err) + expect(cap.errBuf()).toBe( + ' [n1]\na1\n [n2]\nb1\n [n1]\na2\n [n2]\nb2\n', + ) + }) + + it('omits the tag when the chunk carries no node id', () => { + const cap = capture() + const r = new ReasoningChunkRenderer() + r.push({ reasoning: 'plain', nodeId: '', isFinal: true }, cap.err) + expect(cap.errBuf()).toBe('\nplain\n') + }) + + it('flush closes a block left open by a truncated stream', () => { + const cap = capture() + const r = new ReasoningChunkRenderer() + r.push({ reasoning: 'half', nodeId: 'n1', isFinal: false }, cap.err) + r.flush(cap.err) + expect(cap.errBuf()).toBe(' [n1]\nhalf\n') + }) + + it('a lone terminal marker with no reasoning emits nothing', () => { + const cap = capture() + const r = new ReasoningChunkRenderer() + r.push({ reasoning: '', nodeId: 'n1', isFinal: true }, cap.err) + expect(cap.errBuf()).toBe('') + }) +}) + +describe('accumulateReasoning', () => { + it('appends deltas per node, falling back to "_" for a missing nodeId', () => { + const acc: Record = {} + accumulateReasoning(acc, { reasoning: 'a', nodeId: 'n1', isFinal: false }) + accumulateReasoning(acc, { reasoning: 'b', nodeId: 'n1', isFinal: false }) + accumulateReasoning(acc, { reasoning: 'x', nodeId: '', isFinal: false }) + expect(acc).toEqual({ n1: 'ab', _: 'x' }) + }) + + it('ignores empty reasoning', () => { + const acc: Record = {} + accumulateReasoning(acc, { reasoning: '', nodeId: 'n1', isFinal: true }) + expect(acc).toEqual({}) + }) +}) + +describe('formatReasoningBlocks', () => { + it('frames and trims each node, joined by a separator', () => { + expect(formatReasoningBlocks({ n1: ' one ', n2: 'two' })) + .toBe('\none\n\n---\n\ntwo\n') + }) + + it('skips empty entries and returns empty for no reasoning', () => { + expect(formatReasoningBlocks({ n1: ' ' })).toBe('') + expect(formatReasoningBlocks({})).toBe('') + }) +}) + +describe('reasoningBlocksFromMetadata', () => { + it('extracts reasoning from a metadata object', () => { + expect(reasoningBlocksFromMetadata({ reasoning: { n1: 'why' } })) + .toBe('\nwhy\n') + }) + + it('returns empty for tagged mode (empty reasoning) and malformed input', () => { + expect(reasoningBlocksFromMetadata({ reasoning: {} })).toBe('') + expect(reasoningBlocksFromMetadata(undefined)).toBe('') + expect(reasoningBlocksFromMetadata({ usage: { tokens: 1 } })).toBe('') + }) +}) diff --git a/cli/src/sys/io/reasoning.ts b/cli/src/sys/io/reasoning.ts new file mode 100644 index 00000000000..2a88e3ae32b --- /dev/null +++ b/cli/src/sys/io/reasoning.ts @@ -0,0 +1,99 @@ +// Renders "separated"-mode reasoning (streamed on its own `reasoning_chunk` SSE +// channel) to stderr, so --think matches inline (see think-filter.ts). + +const THINK_OPEN = '' +const THINK_CLOSE = '' + +export type ReasoningChunk = { + reasoning: string + nodeId: string + isFinal: boolean +} + +// reasoning_chunk nests its payload under `data` (not top-level like `message`). +export function parseReasoningChunk(parsed: Record): ReasoningChunk | undefined { + const data = parsed.data + if (data === null || typeof data !== 'object' || Array.isArray(data)) + return undefined + const rec = data as Record + return { + reasoning: typeof rec.reasoning === 'string' ? rec.reasoning : '', + nodeId: typeof rec.node_id === 'string' ? rec.node_id : '', + isFinal: rec.is_final === true, + } +} + +// Bucket key for a chunk; falls back to a single bucket so live rendering and +// buffered collection key reasoning the same way. +export function reasoningKey(chunk: ReasoningChunk): string { + return chunk.nodeId !== '' ? chunk.nodeId : '_' +} + +// Appends a reasoning delta to a per-node accumulator. +export function accumulateReasoning(acc: Record, chunk: ReasoningChunk): void { + if (chunk.reasoning === '') + return + const key = reasoningKey(chunk) + acc[key] = (acc[key] ?? '') + chunk.reasoning +} + +// Frames a live reasoning stream into stderr: on the first delta, +// raw deltas thereafter, on is_final. Parallel branches can interleave +// chunks from different nodes on one stream, so it keeps at most one block open, +// switches blocks on node change, and tags each block with its node id so the +// interleaved fragments stay distinguishable. +export class ReasoningChunkRenderer { + private openNode: string | undefined + + push(chunk: ReasoningChunk, errOut: NodeJS.WritableStream): void { + const key = reasoningKey(chunk) + if (chunk.reasoning !== '') { + if (this.openNode !== key) { + this.closeActive(errOut) + errOut.write(chunk.nodeId !== '' ? `${THINK_OPEN} [${chunk.nodeId}]\n` : `${THINK_OPEN}\n`) + this.openNode = key + } + errOut.write(chunk.reasoning) + } + if (chunk.isFinal && this.openNode === key) + this.closeActive(errOut) + } + + // Close a block left open by a truncated stream. + flush(errOut: NodeJS.WritableStream): void { + this.closeActive(errOut) + } + + private closeActive(errOut: NodeJS.WritableStream): void { + if (this.openNode === undefined) + return + errOut.write(`${THINK_CLOSE}\n`) + this.openNode = undefined + } +} + +// Frames fully-buffered reasoning (one entry per LLM node id) into blocks. +export function formatReasoningBlocks(reasoning: Record): string { + const blocks: string[] = [] + for (const text of Object.values(reasoning)) { + const trimmed = text.trim() + if (trimmed !== '') + blocks.push(`${THINK_OPEN}\n${trimmed}\n${THINK_CLOSE}`) + } + return blocks.join('\n---\n') +} + +// Frames per-node reasoning from a message_end `metadata` object; '' when absent. +export function reasoningBlocksFromMetadata(metadata: unknown): string { + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) + return '' + const reasoning = (metadata as Record).reasoning + if (reasoning === null || typeof reasoning !== 'object' || Array.isArray(reasoning)) + return '' + const map: Record = {} + for (const [key, value] of Object.entries(reasoning as Record)) { + if (typeof value === 'string') + map[key] = value + } + return formatReasoningBlocks(map) +} diff --git a/cli/test/e2e/.env.e2e.example b/cli/test/e2e/.env.e2e.example index 53fbedadf31..85f2219daeb 100644 --- a/cli/test/e2e/.env.e2e.example +++ b/cli/test/e2e/.env.e2e.example @@ -67,3 +67,15 @@ DIFY_E2E_PASSWORD= # DIFY_E2E_HITL_SINGLE_ACTION_APP_ID= # DIFY_E2E_HITL_MULTI_NODE_APP_ID= # DIFY_E2E_WS2_APP_ID= + +# ── Separated-mode reasoning suite (opt-in) ───────────────────────────────── +# run-app-reasoning.e2e.ts is skipped unless DIFY_E2E_REASONING_APP_ID resolves. +# It needs a chatflow whose LLM node uses reasoning_format=separated AND a +# workspace with a default chat model configured. +# +# Either point at an existing app: +# DIFY_E2E_REASONING_APP_ID= +# +# …or auto-provision reasoning-chat.yml (→ app name "reasoning-bot"). Off by +# default so the shared bootstrap stays free of any model dependency. +# DIFY_E2E_REASONING_PROVISION=1 diff --git a/cli/test/e2e/README.md b/cli/test/e2e/README.md index fd3e9182507..21635b0f6f9 100644 --- a/cli/test/e2e/README.md +++ b/cli/test/e2e/README.md @@ -39,11 +39,12 @@ test/e2e/ │ ├── describe-app.e2e.ts — describe app │ └── get-app-all-workspaces.e2e.ts — get app -A ([EE] multi-workspace cases) └── run/ - ├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming, - │ conversation, CI mode - ├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing - ├── run-app-file.e2e.ts — --file upload (local + remote URL) - └── run-app-hitl.e2e.ts — HITL pause + resume + ├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming, + │ conversation, CI mode + ├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing + ├── run-app-file.e2e.ts — --file upload (local + remote URL) + ├── run-app-reasoning.e2e.ts — separated-mode reasoning (--think); opt-in + └── run-app-hitl.e2e.ts — HITL pause + resume ``` ## Edition support @@ -137,6 +138,24 @@ global-setup will: | `DIFY_E2E_HITL_SINGLE_ACTION_APP_ID` | | | `DIFY_E2E_HITL_MULTI_NODE_APP_ID` | | | `DIFY_E2E_WS2_APP_ID` | Override secondary workspace app ID (EE) | +| `DIFY_E2E_REASONING_APP_ID` | separated-reasoning chatflow app ID (opt-in) | +| `DIFY_E2E_REASONING_PROVISION` | `1` → auto-provision `reasoning-chat.yml` | + +### Separated-mode reasoning suite (opt-in) + +`run-app-reasoning.e2e.ts` verifies the out-of-band `reasoning_chunk` channel +(PR #37460): `--think` surfaces the chain-of-thought to stderr framed as +``, the answer stays clean, and `-o json` persists it under +`metadata.reasoning`. It is **skipped** unless `DIFY_E2E_REASONING_APP_ID` +resolves, because it runs a real LLM node and needs: + +1. a chatflow whose LLM node uses `reasoning_format: separated`, and +1. a workspace with a default chat model configured. + +Point `DIFY_E2E_REASONING_APP_ID` at such an app, or set +`DIFY_E2E_REASONING_PROVISION=1` to import the `reasoning-chat.yml` fixture +(its system prompt forces a `` block, so any chat model triggers the +separated path — no dedicated reasoning model required). ## Running tests diff --git a/cli/test/e2e/fixtures/apps/reasoning-chat.yml b/cli/test/e2e/fixtures/apps/reasoning-chat.yml new file mode 100644 index 00000000000..ba24be57ef5 --- /dev/null +++ b/cli/test/e2e/fixtures/apps/reasoning-chat.yml @@ -0,0 +1,120 @@ +# Chatflow that exercises separated-mode reasoning (PR #37460): the LLM node sets +# reasoning_format=separated, so the server strips ... from the +# answer and streams the chain-of-thought on the out-of-band `reasoning_chunk` +# channel instead. The system prompt forces a block, so the separated +# path triggers with any chat model — no dedicated reasoning model required. +# +# NOTE: the LLM node leaves model.provider/name empty and relies on the target +# workspace's configured default chat model. The run-app-reasoning E2E suite is +# gated on DIFY_E2E_REASONING_APP_ID, so it is skipped unless a server with a +# working model is wired up. +app: + description: e2e-test reasoning (separated mode) + icon: 🧠 + icon_background: '#FFEAD5' + icon_type: emoji + mode: advanced-chat + name: reasoning-bot + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.6.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: {} + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - id: start-llm + source: '1755189262236' + sourceHandle: source + target: llm + targetHandle: target + - id: llm-answer + source: llm + sourceHandle: source + target: answer + targetHandle: target + nodes: + - data: + desc: '' + title: Start + type: start + variables: [] + id: '1755189262236' + position: + x: 80 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + - data: + context: + enabled: false + variable_selector: [] + desc: '' + memory: + query_prompt_template: '{{#sys.query#}}' + window: + enabled: false + size: 10 + model: + completion_params: + temperature: 0.7 + mode: chat + name: '' + provider: '' + prompt_template: + - role: system + text: >- + You are a helpful assistant. Always reason step by step INSIDE a + single ... block first, then write the final + answer AFTER the closing tag. The final answer must not + contain any tags. + reasoning_format: separated + selected: false + title: LLM + type: llm + variables: [] + vision: + enabled: false + id: llm + position: + x: 380 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + - data: + answer: '{{#llm.text#}}' + desc: '' + title: Answer + type: answer + variables: [] + id: answer + position: + x: 680 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + viewport: + x: 0 + y: 0 + zoom: 1 + rag_pipeline_variables: [] diff --git a/cli/test/e2e/setup/env.ts b/cli/test/e2e/setup/env.ts index 3f71b514426..33f8361d46b 100644 --- a/cli/test/e2e/setup/env.ts +++ b/cli/test/e2e/setup/env.ts @@ -37,6 +37,9 @@ * DIFY_E2E_HITL_EXTERNAL_APP_ID * DIFY_E2E_HITL_SINGLE_ACTION_APP_ID * DIFY_E2E_HITL_MULTI_NODE_APP_ID + * DIFY_E2E_REASONING_APP_ID Override separated-reasoning chatflow app ID + * DIFY_E2E_REASONING_PROVISION=1 Opt in to auto-provisioning reasoning-chat.yml + * (needs a workspace default chat model) */ /** Supported edition values. */ @@ -74,6 +77,12 @@ export type E2EEnv = { fileAppId: string /** Chat app (advanced-chat) with a file input variable */ fileChatAppId: string + /** + * Chatflow whose LLM node uses reasoning_format=separated. Empty unless + * DIFY_E2E_REASONING_APP_ID is set or the fixture is auto-provisioned; the + * run-app-reasoning suite is skipped when empty. + */ + reasoningAppId: string /** * Secondary workspace ID — EE only ("auto_test1"). * Empty in CE mode (CE has a single workspace). @@ -118,6 +127,7 @@ export type E2ECapabilities = { workflowAppId: string fileAppId: string fileChatAppId: string + reasoningAppId: string hitlAppId: string hitlExternalAppId: string hitlSingleActionAppId: string @@ -171,6 +181,7 @@ export function loadE2EEnv(): E2EEnv { hitlMultiNodeAppId: process.env.DIFY_E2E_HITL_MULTI_NODE_APP_ID ?? '', fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '', fileChatAppId: process.env.DIFY_E2E_FILE_CHAT_APP_ID ?? '', + reasoningAppId: process.env.DIFY_E2E_REASONING_APP_ID ?? '', ws2Id: process.env.DIFY_E2E_WS2_ID ?? '', ws2AppId: process.env.DIFY_E2E_WS2_APP_ID ?? '', email: process.env.DIFY_E2E_EMAIL!, @@ -206,6 +217,7 @@ export function resolveEnv(caps: E2ECapabilities | undefined): E2EEnv { workflowAppId: caps.workflowAppId || env.workflowAppId, fileAppId: caps.fileAppId || env.fileAppId, fileChatAppId: caps.fileChatAppId || env.fileChatAppId, + reasoningAppId: caps.reasoningAppId || env.reasoningAppId, hitlAppId: caps.hitlAppId || env.hitlAppId, hitlExternalAppId: caps.hitlExternalAppId || env.hitlExternalAppId, hitlSingleActionAppId: caps.hitlSingleActionAppId || env.hitlSingleActionAppId, diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts index 20b23295acb..92cc29fc1f2 100644 --- a/cli/test/e2e/setup/global-setup.ts +++ b/cli/test/e2e/setup/global-setup.ts @@ -182,6 +182,7 @@ export async function setup(project: TestProject): Promise { workflowAppId: '', fileAppId: '', fileChatAppId: '', + reasoningAppId: '', hitlAppId: '', hitlExternalAppId: '', hitlSingleActionAppId: '', @@ -288,6 +289,7 @@ export async function setup(project: TestProject): Promise { workflowAppId: provisionedIds.DIFY_E2E_WORKFLOW_APP_ID || E.workflowAppId, fileAppId: provisionedIds.DIFY_E2E_FILE_APP_ID || E.fileAppId, fileChatAppId: provisionedIds.DIFY_E2E_FILE_CHAT_APP_ID || E.fileChatAppId, + reasoningAppId: provisionedIds.DIFY_E2E_REASONING_APP_ID || E.reasoningAppId, hitlAppId: provisionedIds.DIFY_E2E_HITL_APP_ID || E.hitlAppId, hitlExternalAppId: provisionedIds.DIFY_E2E_HITL_EXTERNAL_APP_ID || E.hitlExternalAppId, hitlSingleActionAppId: provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId, @@ -503,6 +505,12 @@ async function provisionApps( ['hitl-single-action.yml', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', primaryWsId], ['hitl-multi-node.yml', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', primaryWsId], ['file-chat.yml', 'DIFY_E2E_FILE_CHAT_APP_ID', primaryWsId], + // reasoning-chat.yml runs a real LLM node, so it is opt-in: provisioning it + // requires the workspace to have a default chat model configured. Off by + // default to keep the shared bootstrap free of any model dependency. + ...(process.env.DIFY_E2E_REASONING_PROVISION === '1' + ? [['reasoning-chat.yml', 'DIFY_E2E_REASONING_APP_ID', primaryWsId] as [string, string, string]] + : []), ...(edition === 'ee' ? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]] : []), diff --git a/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts b/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts new file mode 100644 index 00000000000..fd5f12e0b57 --- /dev/null +++ b/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts @@ -0,0 +1,91 @@ +/** + * E2E: difyctl run app — separated-mode reasoning (PR #37460) + * + * Exercises the out-of-band `reasoning_chunk` SSE channel against a real server. + * Requires a chatflow whose LLM node uses reasoning_format=separated AND a + * workspace with a configured chat model. The whole suite is skipped unless + * DIFY_E2E_REASONING_APP_ID resolves (set it directly, or provision the + * reasoning-chat.yml fixture with DIFY_E2E_REASONING_PROVISION=1). + * + * Verifies the client adaptation: + * - --think surfaces the separated reasoning to stderr, framed as + * - the answer (stdout) stays free of + * - -o json persists the reasoning under metadata.reasoning + * - without --think, reasoning stays hidden + */ + +import type { AuthFixture } from '../../helpers/cli.js' +import { afterEach, beforeEach, describe, expect, inject } from 'vitest' +import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js' +import { registerConversation } from '../../helpers/cleanup-registry.js' +import { withAuthFixture } from '../../helpers/cli.js' +import { withRetry } from '../../helpers/retry.js' +import { optionalIt } from '../../helpers/skip.js' +import { resolveEnv } from '../../setup/env.js' + +// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation +const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities +const E = resolveEnv(caps) + +// Skipped unless a separated-reasoning chatflow is wired up (needs a real model). +const reasoningIt = optionalIt(Boolean(E.reasoningAppId)) + +const QUERY = 'In one short sentence, why is the sky blue?' + +describe('E2E / difyctl run app — separated reasoning', () => { + let fx: AuthFixture + + beforeEach(async () => { + fx = await withAuthFixture(E) + }) + afterEach(async () => { + await fx.cleanup() + }) + + reasoningIt('[P1] --think --stream surfaces reasoning on stderr, clean answer on stdout', async () => { + const result = await withRetry( + () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '--stream']), + { attempts: 3, delayMs: 1000 }, + ) + + assertExitCode(result, 0) + expect(result.stdout.trim().length).toBeGreaterThan(0) + // Separated mode keeps the answer free of ; reasoning is framed on stderr. + expect(result.stdout).not.toContain('') + assertStderrContains(result, '') + }) + + reasoningIt('[P1] --think -o json persists reasoning under metadata.reasoning', async () => { + const result = await withRetry( + () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '-o', 'json']), + { attempts: 3, delayMs: 1000 }, + ) + + assertExitCode(result, 0) + const parsed = assertJson<{ + conversation_id?: string + answer: string + metadata?: { reasoning?: Record } + }>(result) + + if (parsed.conversation_id) + registerConversation(E.host, E.token, E.reasoningAppId, parsed.conversation_id) + + const reasoning = parsed.metadata?.reasoning ?? {} + expect(Object.keys(reasoning).length).toBeGreaterThan(0) + expect(Object.values(reasoning).join('').length).toBeGreaterThan(0) + // --think also echoes the separated reasoning to stderr. + assertStderrContains(result, '') + }) + + reasoningIt('[P1] without --think, reasoning stays hidden', async () => { + const result = await withRetry( + () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--stream']), + { attempts: 3, delayMs: 1000 }, + ) + + assertExitCode(result, 0) + expect(result.stdout.trim().length).toBeGreaterThan(0) + expect(result.stderr).not.toContain('') + }) +}) diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 221ccbb6b81..2e0b74d3d54 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -15,6 +15,8 @@ export type Scenario | 'server-version-unsupported' | 'run-422-stale' | 'workflow-think' + | 'chat-reasoning' + | 'workflow-reasoning' | 'import-pending' | 'import-failed' diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 4b119286dbc..766963cd0d5 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -370,6 +370,32 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { ]) return new Response(thinkSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) } + if (scenario === 'chat-reasoning') { + // Separated mode: reasoning streams out-of-band on `reasoning_chunk` (nested + // under `data`), the answer stays free of , and the terminal reasoning + // is persisted into message_end metadata. + const reasoningSse = sseChunks([ + { event: 'reasoning_chunk', data: { data: { message_id: 'msg-1', reasoning: 'secret reasoning', node_id: 'llm-1', is_final: false } } }, + { event: 'reasoning_chunk', data: { data: { message_id: 'msg-1', reasoning: '', node_id: 'llm-1', is_final: true } } }, + { event: 'message', data: { message_id: 'msg-1', conversation_id: 'conv-1', mode: app.mode, answer: 'final answer' } }, + { event: 'message_end', data: { message_id: 'msg-1', conversation_id: 'conv-1', task_id: 'task-1', metadata: { reasoning: { 'llm-1': 'secret reasoning' } } } }, + ]) + return new Response(reasoningSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + } + if (scenario === 'workflow-reasoning') { + // Separated mode in a workflow: reasoning streams out-of-band on + // `reasoning_chunk` (no message_id), outputs stay clean, and there is NO + // persisted metadata — the live deltas are the only source. + const wfReasoningSse = sseChunks([ + { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, + { event: 'node_started', data: { id: 'llm-1', title: 'LLM' } }, + { event: 'reasoning_chunk', data: { data: { reasoning: 'secret reasoning', node_id: 'llm-1', is_final: false } } }, + { event: 'reasoning_chunk', data: { data: { reasoning: '', node_id: 'llm-1', is_final: true } } }, + { event: 'node_finished', data: { id: 'llm-1', status: 'succeeded' } }, + { event: 'workflow_finished', data: { id: 'wf-run-1', workflow_id: 'wf-1', data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: 'final answer' } } } }, + ]) + return new Response(wfReasoningSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + } const sse = streamingRunResponse(app.mode, query, isAgent) return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) }) diff --git a/docker/.env.example b/docker/.env.example index 78ebc3e4df1..9646eeeb735 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -41,6 +41,9 @@ FILES_ACCESS_TIMEOUT=300 # Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. ENABLE_COLLABORATION_MODE=true +# Learn app feature toggle +ENABLE_LEARN_APP=true + # Logging and server workers LOG_LEVEL=INFO LOG_OUTPUT_FORMAT=text diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 26274fe87d2..391dba2e21a 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -18,6 +18,9 @@ MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 # Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. ENABLE_COLLABORATION_MODE=true + +# Learn app feature toggle +ENABLE_LEARN_APP=true CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_TASK_ANNOTATIONS=null AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index f1dcc7fc4b4..a510faa22da 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -13,6 +13,7 @@ export type SystemFeatureModel = { enable_email_code_login: boolean enable_email_password_login: boolean enable_explore_banner: boolean + enable_learn_app: boolean enable_marketplace: boolean enable_social_oauth_login: boolean enable_trial_app: boolean diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 80b27e7843a..e6f2b2fc5a7 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -105,6 +105,7 @@ export const zSystemFeatureModel = z.object({ enable_email_code_login: z.boolean().default(false), enable_email_password_login: z.boolean().default(true), enable_explore_banner: z.boolean().default(false), + enable_learn_app: z.boolean().default(true), enable_marketplace: z.boolean().default(false), enable_social_oauth_login: z.boolean().default(false), enable_trial_app: z.boolean().default(false), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 61c9cf103be..722b3042841 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -556,6 +556,7 @@ export type SystemFeatureModel = { enable_email_code_login: boolean enable_email_password_login: boolean enable_explore_banner: boolean + enable_learn_app: boolean enable_marketplace: boolean enable_social_oauth_login: boolean enable_trial_app: boolean diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 8045ad341e3..d555ad5f85c 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -840,6 +840,7 @@ export const zSystemFeatureModel = z.object({ enable_email_code_login: z.boolean().default(false), enable_email_password_login: z.boolean().default(true), enable_explore_banner: z.boolean().default(false), + enable_learn_app: z.boolean().default(true), enable_marketplace: z.boolean().default(false), enable_social_oauth_login: z.boolean().default(false), enable_trial_app: z.boolean().default(false), diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index ada0afe17fa..81e27381649 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -379,6 +379,22 @@ describe('Apps', () => { }) }) + it('should hide categories without templates even when the API returns them', () => { + mockUseExploreAppList.mockReturnValueOnce({ + data: { + categories: ['Cat A', 'v'], + allList: [createAppEntry('Alpha', 'Cat A')], + }, + isLoading: false, + }) + + render() + + expect(screen.getByText('Cat A'))!.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'v' })).not.toBeInTheDocument() + expect(screen.getByText('Alpha'))!.toBeInTheDocument() + }) + it('should clear the search, hide the sidebar during search, and close the modal when requested', async () => { render() diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index c97b8b5fcb5..ac660cdb1fc 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -77,14 +77,30 @@ const Apps = ({ isLoading, } = useExploreAppList() + const visibleCategories = useMemo(() => { + if (!data) + return [] + + const categoriesWithApps = new Set() + data.allList.forEach((app) => { + app.categories.forEach(category => categoriesWithApps.add(category)) + }) + + return data.categories.filter(category => categoriesWithApps.has(category)) + }, [data]) + + const activeCategory = visibleCategories.includes(currCategory) + ? currCategory + : allCategoriesEn + const filteredList = useMemo(() => { if (!data) return [] const { allList } = data const filteredByCategory = allList.filter((item) => { - if (currCategory === allCategoriesEn) + if (activeCategory === allCategoriesEn) return true - return item.categories?.includes(currCategory) ?? false + return item.categories?.includes(activeCategory) ?? false }) if (currentType.length === 0) return filteredByCategory @@ -101,7 +117,7 @@ const Apps = ({ return true return false }) - }, [currentType, currCategory, allCategoriesEn, data]) + }, [currentType, activeCategory, allCategoriesEn, data]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) @@ -189,7 +205,7 @@ const Apps = ({
{!searchKeywords && (
- { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} /> + { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
)}
@@ -200,7 +216,7 @@ const Apps = ({ ?

{searchFilteredList.length > 1 ? t('newApp.foundResults', { ns: 'app', count: searchFilteredList.length }) : t('newApp.foundResult', { ns: 'app', count: searchFilteredList.length })}

: (
- +
)}
diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 47f328d50d6..2e198eb5cb6 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,3 +1,4 @@ +import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -324,10 +325,14 @@ beforeAll(() => { // Render helper wrapping with shared nuqs testing helper plus a seeded // systemFeatures cache so List can resolve its useSuspenseQuery. -const renderList = (searchParams = '') => { +type RenderListOptions = { + systemFeatures?: Partial +} + +const renderList = (searchParams = '', options: RenderListOptions = {}) => { mockSearchParams = new URLSearchParams(searchParams) const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ - systemFeatures: { branding: { enabled: false } }, + systemFeatures: { branding: { enabled: false }, ...options.systemFeatures }, }) return renderWithNuqs(, { searchParams }) } @@ -502,7 +507,7 @@ describe('List', () => { it('should render first empty state when there are no apps and no active filters', () => { mockAppData = { pages: [{ data: [], total: 0 }] } - renderList() + renderList('', { systemFeatures: { enable_learn_app: true } }) expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument() expect(screen.getByText('app.firstEmpty.learnDifyTitle'))!.toBeInTheDocument() @@ -512,6 +517,15 @@ describe('List', () => { expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() }) + it('should hide learn dify in first empty state when learn app is disabled', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + + renderList('', { systemFeatures: { enable_learn_app: false } }) + + expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument() + expect(screen.queryByText('app.firstEmpty.learnDifyTitle')).not.toBeInTheDocument() + }) + it('should not render first empty state before the first app list page resolves', () => { mockAppData = { pages: [] } diff --git a/web/app/components/apps/first-empty-state/index.tsx b/web/app/components/apps/first-empty-state/index.tsx index 603a6dd9896..f1dc101073d 100644 --- a/web/app/components/apps/first-empty-state/index.tsx +++ b/web/app/components/apps/first-empty-state/index.tsx @@ -19,12 +19,14 @@ type Props = { onCreateBlank: () => void onCreateTemplate: () => void onImportDSL: () => void + showLearnDify: boolean } function FirstEmptyState({ onCreateBlank, onCreateTemplate, onImportDSL, + showLearnDify, }: Props) { const { t } = useTranslation() @@ -102,13 +104,15 @@ function FirstEmptyState({
- + {showLearnDify && ( + + )} ) } diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 7af2adc1a05..20a197e8ffe 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -263,6 +263,7 @@ function List({ onCreateBlank={openCreateBlankModal} onCreateTemplate={openCreateTemplateDialog} onImportDSL={openCreateFromDSLModal} + showLearnDify={systemFeatures.enable_learn_app} /> ) : ( diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index a11efc38143..f42e34af994 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -286,6 +286,7 @@ const mockAppCreatePermission = (hasEditPermission: boolean) => { type RenderOptions = { enableExploreBanner?: boolean + enableLearnApp?: boolean isCloudEdition?: boolean } @@ -302,7 +303,10 @@ const renderAppList = ( mockConfig.isCloudEdition = options.isCloudEdition ?? false mockAppCreatePermission(hasEditPermission) const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({ - systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false }, + systemFeatures: { + enable_explore_banner: options.enableExploreBanner ?? false, + enable_learn_app: options.enableLearnApp ?? true, + }, }) if (!mockIsLoading && !mockIsError && mockExploreData) queryClient.setQueryData(exploreAppListQueryKey, mockExploreData) @@ -542,6 +546,18 @@ describe('AppList', () => { expect(screen.queryByText('3 min')).not.toBeInTheDocument() }) + it('should hide learn dify templates when learn app is disabled', () => { + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + + renderAppList(false, undefined, undefined, { enableLearnApp: false }) + + expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument() + expect(screen.queryByText('Learn Workflow Basics')).not.toBeInTheDocument() + }) + it('should collapse learn dify and persist hidden state when hide is clicked', async () => { mockExploreData = { categories: ['Writing'], @@ -578,6 +594,18 @@ describe('AppList', () => { expect(screen.queryByText('Beta')).not.toBeInTheDocument() }) + it('should hide categories without apps even when the API returns them', () => { + mockExploreData = { + categories: ['Writing', 'c'], + allList: [createApp()], + } + + renderAppList(false, undefined, { category: 'c' }) + + expect(screen.queryByRole('radio', { name: 'c' })).not.toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + }) + it('should keep selected category when clearing search text', async () => { mockExploreData = { categories: ['Writing', 'Translate'], diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 00431c36486..feb253173e7 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -142,15 +142,31 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { defaultValue: allCategoriesEn, }) + const visibleCategories = useMemo(() => { + if (!homeQueries.appListData) + return [] + + const categoriesWithApps = new Set() + homeQueries.appListData.allList.forEach((app) => { + app.categories.forEach(category => categoriesWithApps.add(category)) + }) + + return homeQueries.appListData.categories.filter(category => categoriesWithApps.has(category)) + }, [homeQueries.appListData]) + + const activeCategory = visibleCategories.includes(currCategory) + ? currCategory + : allCategoriesEn + const filteredList = useMemo(() => { if (!homeQueries.appListData) return [] return homeQueries.appListData.allList.filter( item => - currCategory === allCategoriesEn - || item.categories?.includes(currCategory), + activeCategory === allCategoriesEn + || item.categories?.includes(activeCategory), ) - }, [homeQueries.appListData, currCategory, allCategoriesEn]) + }, [homeQueries.appListData, activeCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) @@ -292,8 +308,8 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { { } const LearnDify = (props: LearnDifyProps) => { + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + + if (!systemFeatures.enable_learn_app) + return null + if (props.dismissible === false) return diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 0913e0c7482..c5b81b2514d 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -906,7 +906,7 @@ describe('MainNav', () => { it('shows Learn Dify switch in help menu and restores it from localStorage', async () => { localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true') - renderMainNav() + renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const learnDifyItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' }) @@ -920,8 +920,17 @@ describe('MainNav', () => { expect(mockPush).not.toHaveBeenCalled() }) + it('hides Learn Dify switch in help menu when learn app is disabled', async () => { + renderMainNav({ enable_learn_app: false }) + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + await screen.findByText('common.mainNav.help.docs') + expect(screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' })).not.toBeInTheDocument() + }) + it('orders help menu items to match the nav shell design', async () => { - renderMainNav() + renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index 647ef49bdb2..f46834424b2 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -56,6 +56,7 @@ const HelpMenu = ({ const setLearnDifyHidden = useSetLearnDifyHidden() const [aboutVisible, setAboutVisible] = useState(false) const [open, setOpen] = useState(false) + const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app if (systemFeatures.branding.enabled) return null @@ -95,31 +96,33 @@ const HelpMenu = ({ trailing={} /> - setLearnDifyHidden(!checked)} - > - - - {t('mainNav.help.learnDify', { ns: 'common' })} - - setLearnDifyHidden(!checked)} > + + + {t('mainNav.help.learnDify', { ns: 'common' })} + - - + > + + + + )} {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && } diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts index 3756381822e..000c8fa532f 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts @@ -40,6 +40,7 @@ const createHandlers = () => ({ handleWorkflowAgentLog: vi.fn(), handleWorkflowTextChunk: vi.fn(), handleWorkflowTextReplace: vi.fn(), + handleWorkflowReasoning: vi.fn(), handleWorkflowPaused: vi.fn(), }) @@ -252,6 +253,7 @@ describe('useWorkflowRun callbacks helpers', () => { callbacks.onAgentLog?.({ node_id: 'node-1' } as never) callbacks.onTextChunk?.({ data: 'chunk' } as never) callbacks.onTextReplace?.({ text: 'replacement' } as never) + callbacks.onReasoning?.({ data: { reasoning: 'thinking', node_id: 'node-1' } } as never) callbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never) callbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never) callbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never) @@ -295,6 +297,7 @@ describe('useWorkflowRun callbacks helpers', () => { expect(userCallbacks.onAgentLog).toHaveBeenCalled() expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled() expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled() + expect(handlers.handleWorkflowReasoning).toHaveBeenCalled() expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled() expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled() expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled() @@ -423,6 +426,7 @@ describe('useWorkflowRun callbacks helpers', () => { finalCallbacks.onAgentLog?.({ node_id: 'node-1' } as never) finalCallbacks.onTextChunk?.({ data: 'chunk' } as never) finalCallbacks.onTextReplace?.({ text: 'replacement' } as never) + finalCallbacks.onReasoning?.({ data: { reasoning: 'thinking', node_id: 'node-1' } } as never) finalCallbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never) finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never) finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never) @@ -461,6 +465,7 @@ describe('useWorkflowRun callbacks helpers', () => { expect(userCallbacks.onAgentLog).toHaveBeenCalled() expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled() expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled() + expect(handlers.handleWorkflowReasoning).toHaveBeenCalled() expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled() expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled() expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled() diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts b/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts index e8820f755c5..3b1c30b385c 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts @@ -27,6 +27,7 @@ type WorkflowRunEventHandlers = { handleWorkflowAgentLog: NonNullable handleWorkflowTextChunk: NonNullable handleWorkflowTextReplace: NonNullable + handleWorkflowReasoning: NonNullable handleWorkflowPaused: () => void } @@ -114,6 +115,7 @@ export const createBaseWorkflowRunCallbacks = ({ handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, + handleWorkflowReasoning, handleWorkflowPaused, } = handlers const { @@ -244,6 +246,9 @@ export const createBaseWorkflowRunCallbacks = ({ onTextReplace: (params) => { handleWorkflowTextReplace(params) }, + onReasoning: (params) => { + handleWorkflowReasoning(params) + }, onTTSChunk: (messageId: string, audio: string) => { if (!audio || audio === '') return @@ -325,6 +330,7 @@ export const createFinalWorkflowRunCallbacks = ({ handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, + handleWorkflowReasoning, handleWorkflowPaused, } = handlers const { @@ -439,6 +445,9 @@ export const createFinalWorkflowRunCallbacks = ({ onTextReplace: (params) => { handleWorkflowTextReplace(params) }, + onReasoning: (params) => { + handleWorkflowReasoning(params) + }, onTTSChunk: (messageId: string, audio: string) => { if (!audio || audio === '') return diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts index eae758cf2f8..80a09f1c67c 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts @@ -78,6 +78,8 @@ export const createRunningWorkflowState = () => { }, tracing: [], resultText: '', + reasoningContent: {}, + reasoningFinished: false, } } diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index d7a90e36605..7254c646ee9 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -138,6 +138,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, + handleWorkflowReasoning, handleWorkflowPaused, } = useWorkflowRunEvent() @@ -326,6 +327,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, + handleWorkflowReasoning, handleWorkflowPaused, } const userCallbacks = { @@ -443,7 +445,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { }, finalCallbacks, ) - }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout]) + }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowReasoning, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout]) const handleStopRun = useCallback((taskId: string) => { const setStoppedState = () => { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts new file mode 100644 index 00000000000..ebbb547f706 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts @@ -0,0 +1,61 @@ +import type { ReasoningChunkResponse } from '@/types/workflow' +import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env' +import { useWorkflowReasoning } from '../use-workflow-reasoning' + +const reasoningChunk = (data: Partial): ReasoningChunkResponse => ({ + task_id: 'task-1', + event: 'reasoning_chunk', + data: { message_id: '', reasoning: '', ...data }, +}) + +describe('useWorkflowReasoning', () => { + it('accumulates reasoning deltas per LLM node id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), { + initialStoreState: { + workflowRunningData: baseRunningData({ resultText: '' }), + }, + }) + + result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'let me ', node_id: 'llm' })) + result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'think', node_id: 'llm' })) + + const state = store.getState().workflowRunningData! + expect(state.reasoningContent).toEqual({ llm: 'let me think' }) + expect(state.reasoningFinished).toBeFalsy() + }) + + it('keeps reasoning from multiple LLM nodes in separate buckets', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), { + initialStoreState: { workflowRunningData: baseRunningData({ resultText: '' }) }, + }) + + result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'a', node_id: 'llm-1' })) + result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'b', node_id: 'llm-2' })) + + expect(store.getState().workflowRunningData!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b' }) + }) + + it('falls back to "_" when the chunk carries no node id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), { + initialStoreState: { workflowRunningData: baseRunningData({ resultText: '' }) }, + }) + + result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'x' })) + + expect(store.getState().workflowRunningData!.reasoningContent).toEqual({ _: 'x' }) + }) + + it('marks reasoning finished on the terminal marker without appending empty text', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), { + initialStoreState: { + workflowRunningData: baseRunningData({ resultText: '', reasoningContent: { llm: 'done' } }), + }, + }) + + result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: '', node_id: 'llm', is_final: true })) + + const state = store.getState().workflowRunningData! + expect(state.reasoningContent).toEqual({ llm: 'done' }) + expect(state.reasoningFinished).toBe(true) + }) +}) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts index fb8ea51638a..6010c791702 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts @@ -16,6 +16,7 @@ const handlers = vi.hoisted(() => ({ handleWorkflowNodeRetry: vi.fn(), handleWorkflowTextChunk: vi.fn(), handleWorkflowTextReplace: vi.fn(), + handleWorkflowReasoning: vi.fn(), handleWorkflowAgentLog: vi.fn(), handleWorkflowPaused: vi.fn(), handleWorkflowNodeHumanInputRequired: vi.fn(), @@ -45,6 +46,10 @@ vi.mock('..', () => ({ useWorkflowNodeHumanInputFormTimeout: () => ({ handleWorkflowNodeHumanInputFormTimeout: handlers.handleWorkflowNodeHumanInputFormTimeout }), })) +vi.mock('../use-workflow-reasoning', () => ({ + useWorkflowReasoning: () => ({ handleWorkflowReasoning: handlers.handleWorkflowReasoning }), +})) + describe('useWorkflowRunEvent', () => { it('returns the composed handlers from all workflow event hooks', () => { const { result } = renderHook(() => useWorkflowRunEvent()) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts new file mode 100644 index 00000000000..9abfeca0383 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts @@ -0,0 +1,30 @@ +import type { ReasoningChunkResponse } from '@/types/workflow' +import { produce } from 'immer' +import { useCallback } from 'react' +import { useWorkflowStore } from '@/app/components/workflow/store' + +export const useWorkflowReasoning = () => { + const workflowStore = useWorkflowStore() + + const handleWorkflowReasoning = useCallback((params: ReasoningChunkResponse) => { + const { data: { reasoning, node_id, is_final } } = params + const { + workflowRunningData, + setWorkflowRunningData, + } = workflowStore.getState() + + setWorkflowRunningData(produce(workflowRunningData!, (draft) => { + const reasoningContent = (draft.reasoningContent ||= {}) + // key by LLM node so multiple nodes' reasoning stays separated + const key = node_id || '_' + if (reasoning) + reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) + draft.reasoningFinished = true + })) + }, [workflowStore]) + + return { + handleWorkflowReasoning, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts index bf8fd319a2d..2366fdd9684 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts @@ -19,6 +19,7 @@ import { useWorkflowTextChunk, useWorkflowTextReplace, } from '.' +import { useWorkflowReasoning } from './use-workflow-reasoning' export const useWorkflowRunEvent = () => { const { handleWorkflowStarted } = useWorkflowStarted() @@ -35,6 +36,7 @@ export const useWorkflowRunEvent = () => { const { handleWorkflowNodeRetry } = useWorkflowNodeRetry() const { handleWorkflowTextChunk } = useWorkflowTextChunk() const { handleWorkflowTextReplace } = useWorkflowTextReplace() + const { handleWorkflowReasoning } = useWorkflowReasoning() const { handleWorkflowAgentLog } = useWorkflowAgentLog() const { handleWorkflowPaused } = useWorkflowPaused() const { handleWorkflowNodeHumanInputRequired } = useWorkflowNodeHumanInputRequired() @@ -56,6 +58,7 @@ export const useWorkflowRunEvent = () => { handleWorkflowNodeRetry, handleWorkflowTextChunk, handleWorkflowTextReplace, + handleWorkflowReasoning, handleWorkflowAgentLog, handleWorkflowPaused, handleWorkflowNodeHumanInputFormFilled, diff --git a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx index a3d629d1708..93d51acd78d 100644 --- a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx @@ -71,6 +71,12 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ default: ({ list }: { list: unknown[] }) =>
{list.length}
, })) +vi.mock('@/app/components/base/chat/chat/answer/reasoning-panel', () => ({ + default: ({ content, done }: { content: Record, done: boolean }) => ( +
{Object.keys(content).join(',')}
+ ), +})) + vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({ default: ({ onRun }: { onRun: () => void }) => (