Merge remote-tracking branch 'origin/main' into feat/agent-v2

This commit is contained in:
yyh 2026-06-24 17:11:22 +08:00
commit c5558b562c
No known key found for this signature in database
75 changed files with 1742 additions and 164 deletions

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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")

View File

@ -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),

View File

@ -19692,6 +19692,7 @@ Model class for provider system configuration response.
| enable_email_code_login | boolean | | Yes |
| enable_email_password_login | boolean, <br>**Default:** true | | Yes |
| enable_explore_banner | boolean | | Yes |
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_trial_app | boolean | | Yes |

View File

@ -1603,6 +1603,7 @@ Default configuration for form inputs.
| enable_email_code_login | boolean | | Yes |
| enable_email_password_login | boolean, <br>**Default:** true | | Yes |
| enable_explore_banner | boolean | | Yes |
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_trial_app | boolean | | Yes |

View File

@ -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)."

View File

@ -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]

View File

@ -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]:

View File

@ -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)

View File

@ -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)

View File

@ -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: ...

View File

@ -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

View File

@ -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"]:

View File

@ -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",

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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"

View File

@ -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,

View File

@ -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")

View File

@ -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

175
api/uv.lock generated
View File

@ -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]]

View File

@ -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 <think>...</think> 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 <think>...</think> 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,
}

View File

@ -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)) {

View File

@ -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 <think>...</think> 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 <think>...</think> 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: '' }),

View File

@ -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('<think>')
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<string, string> } }
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('<think>')
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<string, string> } }
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()

View File

@ -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(

View File

@ -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<string, unknown> = {}
private metadata: Record<string, unknown> | undefined
private thoughts: unknown[] = []
private readonly reasoning: Record<string, string> = {}
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<string, unknown> = { 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<string, unknown> | 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<string, unknown> = {}
@ -133,14 +153,29 @@ class CompletionCollector implements Collector {
class WorkflowCollector implements Collector {
private final: Record<string, unknown> | undefined
private readonly reasoning: Record<string, string> = {}
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<string, unknown> {
return { mode: RUN_MODES.Workflow, ...(this.final ?? {}) }
const out: Record<string, unknown> = { 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<string, unknown>
: undefined
out.metadata = { ...(existing ?? {}), reasoning: this.reasoning }
}
return out
}
}

View File

@ -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('<think> [llm-1]\npondering</think>')
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('<think> [llm-1]\nthinking</think>')
})
})
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('<think> [llm-1]\npondering</think>')
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('<think> [llm-1]\nthinking</think>')
})
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(
'<think> [llm-1]\na1</think>\n<think> [llm-2]\nb1</think>\n<think> [llm-1]\na2</think>\n<think> [llm-2]\nb2</think>\n',
)
})
})
describe('streamPrinterFor — unknown mode', () => {
it('throws', () => {
expect(() => streamPrinterFor('whatever')).toThrow()

View File

@ -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<string, unknown> | 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

View File

@ -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 <think> 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('<think> [llm-1]\npondering</think>\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('<think> [n1]\na</think>\n<think> [n2]\nb</think>\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(
'<think> [n1]\na1</think>\n<think> [n2]\nb1</think>\n<think> [n1]\na2</think>\n<think> [n2]\nb2</think>\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('<think>\nplain</think>\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('<think> [n1]\nhalf</think>\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<string, string> = {}
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<string, string> = {}
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('<think>\none\n</think>\n---\n<think>\ntwo\n</think>')
})
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('<think>\nwhy\n</think>')
})
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('')
})
})

View File

@ -0,0 +1,99 @@
// Renders "separated"-mode reasoning (streamed on its own `reasoning_chunk` SSE
// channel) to stderr, so --think matches inline <think> (see think-filter.ts).
const THINK_OPEN = '<think>'
const THINK_CLOSE = '</think>'
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<string, unknown>): ReasoningChunk | undefined {
const data = parsed.data
if (data === null || typeof data !== 'object' || Array.isArray(data))
return undefined
const rec = data as Record<string, unknown>
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<string, string>, chunk: ReasoningChunk): void {
if (chunk.reasoning === '')
return
const key = reasoningKey(chunk)
acc[key] = (acc[key] ?? '') + chunk.reasoning
}
// Frames a live reasoning stream into stderr: <think> on the first delta,
// raw deltas thereafter, </think> 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 <think> blocks.
export function formatReasoningBlocks(reasoning: Record<string, string>): 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<string, unknown>).reasoning
if (reasoning === null || typeof reasoning !== 'object' || Array.isArray(reasoning))
return ''
const map: Record<string, string> = {}
for (const [key, value] of Object.entries(reasoning as Record<string, unknown>)) {
if (typeof value === 'string')
map[key] = value
}
return formatReasoningBlocks(map)
}

View File

@ -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

View File

@ -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
`<think>…</think>`, 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 `<think>` block, so any chat model triggers the
separated path — no dedicated reasoning model required).
## Running tests

View File

@ -0,0 +1,120 @@
# Chatflow that exercises separated-mode reasoning (PR #37460): the LLM node sets
# reasoning_format=separated, so the server strips <think>...</think> from the
# answer and streams the chain-of-thought on the out-of-band `reasoning_chunk`
# channel instead. The system prompt forces a <think> 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 <think>...</think> block first, then write the final
answer AFTER the closing </think> tag. The final answer must not
contain any <think> 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: []

View File

@ -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,

View File

@ -182,6 +182,7 @@ export async function setup(project: TestProject): Promise<void> {
workflowAppId: '',
fileAppId: '',
fileChatAppId: '',
reasoningAppId: '',
hitlAppId: '',
hitlExternalAppId: '',
hitlSingleActionAppId: '',
@ -288,6 +289,7 @@ export async function setup(project: TestProject): Promise<void> {
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]]
: []),

View File

@ -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 <think></think>
* - the answer (stdout) stays free of <think>
* - -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 <think>; reasoning is framed on stderr.
expect(result.stdout).not.toContain('<think>')
assertStderrContains(result, '<think>')
})
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<string, string> }
}>(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, '<think>')
})
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('<think>')
})
})

View File

@ -15,6 +15,8 @@ export type Scenario
| 'server-version-unsupported'
| 'run-422-stale'
| 'workflow-think'
| 'chat-reasoning'
| 'workflow-reasoning'
| 'import-pending'
| 'import-failed'

View File

@ -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 <think>, 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' } })
})

View File

@ -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

View File

@ -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://<your_account_name>.blob.core.windows.net

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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),

View File

@ -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(<Apps />)
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(<Apps />)

View File

@ -77,14 +77,30 @@ const Apps = ({
isLoading,
} = useExploreAppList()
const visibleCategories = useMemo(() => {
if (!data)
return []
const categoriesWithApps = new Set<string>()
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 = ({
<div className="relative flex flex-1 overflow-y-auto">
{!searchKeywords && (
<div className="h-full w-[200px] p-4">
<Sidebar current={currCategory as AppCategories} categories={data?.categories || []} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
<Sidebar current={activeCategory as AppCategories} categories={visibleCategories} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
</div>
)}
<div className="h-full flex-1 shrink-0 grow overflow-auto border-l border-divider-burn p-6 pt-2">
@ -200,7 +216,7 @@ const Apps = ({
? <p className="title-md-semi-bold text-text-tertiary">{searchFilteredList.length > 1 ? t('newApp.foundResults', { ns: 'app', count: searchFilteredList.length }) : t('newApp.foundResult', { ns: 'app', count: searchFilteredList.length })}</p>
: (
<div className="flex h-[22px] items-center">
<AppCategoryLabel category={currCategory as AppCategories} className="title-md-semi-bold text-text-primary" />
<AppCategoryLabel category={activeCategory as AppCategories} className="title-md-semi-bold text-text-primary" />
</div>
)}
</div>

View File

@ -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<GetSystemFeaturesResponse>
}
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(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { 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: [] }

View File

@ -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({
</div>
</section>
</div>
<LearnDify
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
showDescription
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
/>
{showLearnDify && (
<LearnDify
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
showDescription
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
/>
)}
</div>
)
}

View File

@ -263,6 +263,7 @@ function List({
onCreateBlank={openCreateBlankModal}
onCreateTemplate={openCreateTemplateDialog}
onImportDSL={openCreateFromDSLModal}
showLearnDify={systemFeatures.enable_learn_app}
/>
)
: (

View File

@ -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'],

View File

@ -142,15 +142,31 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
defaultValue: allCategoriesEn,
})
const visibleCategories = useMemo(() => {
if (!homeQueries.appListData)
return []
const categoriesWithApps = new Set<string>()
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 }) => {
<ExploreAppListHeader
allCategoriesEn={allCategoriesEn}
categories={homeQueries.appListData?.categories ?? []}
currCategory={currCategory}
categories={visibleCategories}
currCategory={activeCategory}
keywords={keywords}
onCategoryChange={setCurrCategory}
onKeywordsChange={handleKeywordsChange}

View File

@ -3,9 +3,11 @@
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useLearnDifyAppList } from '@/service/use-explore'
import LearnDifyItem from './item'
import { useLearnDifyHiddenValue, useSetLearnDifyHidden } from './storage'
@ -142,6 +144,11 @@ const DismissibleLearnDify = (props: LearnDifyProps) => {
}
const LearnDify = (props: LearnDifyProps) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
if (!systemFeatures.enable_learn_app)
return null
if (props.dismissible === false)
return <LearnDifyContent {...props} />

View File

@ -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' }))

View File

@ -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={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
<DropdownMenuCheckboxItem
checked={!learnDifyHidden}
closeOnClick={false}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
onCheckedChange={checked => setLearnDifyHidden(!checked)}
>
<span aria-hidden className="i-custom-vender-workflow-docs-extractor size-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t('mainNav.help.learnDify', { ns: 'common' })}
</span>
<span
aria-hidden
className={cn(
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors',
!learnDifyHidden ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
)}
{shouldShowLearnDifySwitch && (
<DropdownMenuCheckboxItem
checked={!learnDifyHidden}
closeOnClick={false}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
onCheckedChange={checked => setLearnDifyHidden(!checked)}
>
<span aria-hidden className="i-custom-vender-workflow-docs-extractor size-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t('mainNav.help.learnDify', { ns: 'common' })}
</span>
<span
aria-hidden
className={cn(
'block h-3 w-2.5 rounded-[3px] bg-components-toggle-knob shadow-sm transition-transform',
!learnDifyHidden && 'translate-x-3.5',
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors',
!learnDifyHidden ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
)}
/>
</span>
</DropdownMenuCheckboxItem>
>
<span
className={cn(
'block h-3 w-2.5 rounded-[3px] bg-components-toggle-knob shadow-sm transition-transform',
!learnDifyHidden && 'translate-x-3.5',
)}
/>
</span>
</DropdownMenuCheckboxItem>
)}
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />

View File

@ -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()

View File

@ -27,6 +27,7 @@ type WorkflowRunEventHandlers = {
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
handleWorkflowReasoning: NonNullable<IOtherOptions['onReasoning']>
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

View File

@ -78,6 +78,8 @@ export const createRunningWorkflowState = () => {
},
tracing: [],
resultText: '',
reasoningContent: {},
reasoningFinished: false,
}
}

View File

@ -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 = () => {

View File

@ -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['data']>): 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)
})
})

View File

@ -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())

View File

@ -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,
}
}

View File

@ -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,

View File

@ -71,6 +71,12 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: unknown[] }) => <div data-testid="tracing-panel">{list.length}</div>,
}))
vi.mock('@/app/components/base/chat/chat/answer/reasoning-panel', () => ({
default: ({ content, done }: { content: Record<string, string>, done: boolean }) => (
<div data-testid="reasoning-panel" data-done={String(done)}>{Object.keys(content).join(',')}</div>
),
}))
vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({
default: ({ onRun }: { onRun: () => void }) => (
<button type="button" onClick={onRun}>
@ -341,6 +347,80 @@ describe('WorkflowPreview', () => {
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
it('should render a single merged reasoning panel above the result on the result tab', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({ status: WorkflowRunningStatus.Running }),
}),
resultText: '',
reasoningContent: { 'llm-1': 'thinking a', 'llm-2': 'thinking b' },
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
await user.click(screen.getByText('runLog.result'))
// one panel that carries both nodes' reasoning; still running → timer keeps ticking
const panels = screen.getAllByTestId('reasoning-panel')
expect(panels).toHaveLength(1)
expect(panels[0]).toHaveTextContent('llm-1,llm-2')
expect(panels[0]).toHaveAttribute('data-done', 'false')
})
it('should mark reasoning done once the answer starts streaming while still running', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({ status: WorkflowRunningStatus.Running }),
}),
resultText: 'the answer',
reasoningContent: { 'llm-1': 'thinking a' },
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
await user.click(screen.getByText('runLog.result'))
// answer-started (resultText non-empty) freezes the timer even though the run is still Running
expect(screen.getByTestId('reasoning-panel')).toHaveAttribute('data-done', 'true')
})
it('should not render a reasoning panel when there is no reasoning content', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({ status: WorkflowRunningStatus.Running }),
}),
resultText: '',
reasoningContent: { llm: '' },
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
await user.click(screen.getByText('runLog.result'))
expect(screen.queryByTestId('reasoning-panel')).not.toBeInTheDocument()
})
it('should switch to the tracing tab when result panel requests it', async () => {
const user = userEvent.setup()

View File

@ -10,6 +10,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ReasoningPanel from '@/app/components/base/chat/chat/answer/reasoning-panel'
import Loading from '@/app/components/base/loading'
import { submitHumanInputForm } from '@/service/workflow'
import {
@ -203,6 +204,17 @@ const WorkflowPreview = () => {
humanInputFilledFormDataList={humanInputFilledFormDataList}
/>
)}
{workflowRunningData?.reasoningContent && Object.values(workflowRunningData.reasoningContent).some(Boolean) && (
<ReasoningPanel
content={workflowRunningData.reasoningContent}
// freeze the timer once the answer starts streaming — reasoningFinished and status only flip at run end
done={
!!workflowRunningData?.resultText?.trim()
|| !!workflowRunningData?.reasoningFinished
|| workflowRunningData?.result?.status !== WorkflowRunningStatus.Running
}
/>
)}
<ResultText
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
isPaused={workflowRunningData?.result?.status === WorkflowRunningStatus.Paused}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { ChatContextProvider } from '@/app/components/base/chat/chat/context-provider'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import { FileList } from '@/app/components/base/file-uploader'
import { ImageIndentLeft } from '@/app/components/base/icons/src/vender/line/editor'
@ -60,7 +61,10 @@ const ResultText: FC<ResultTextProps> = ({
<>
{outputs && (
<div className="px-4 py-2">
<Markdown content={outputs} />
{/* ThinkBlock's timer reads isResponding from ChatContext, which the run panel otherwise lacks. */}
<ChatContextProvider chatList={[]} isResponding={!!isRunning}>
<Markdown content={outputs} />
</ChatContextProvider>
</div>
)}
{!!allFiles?.length && allFiles.map(item => (

View File

@ -11,6 +11,10 @@ type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
resultText?: string
resultTextSelectorKey?: string
// separated-mode reasoning deltas per LLM node id (live preview only)
reasoningContent?: Record<string, string>
// true once the terminal reasoning marker arrived
reasoningFinished?: boolean
// human input form schema or data cached when node is in 'Paused' status
extraContentAndFormData?: Record<string, unknown>
}

View File

@ -42,6 +42,7 @@ export NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=${NEXT_PUBLIC_ENABLE_CHANGE_EMAIL:-${ENAB
export NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=${NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED:-${CREATORS_PLATFORM_FEATURES_ENABLED}}
export NEXT_PUBLIC_ENABLE_TRIAL_APP=${NEXT_PUBLIC_ENABLE_TRIAL_APP:-${ENABLE_TRIAL_APP}}
export NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=${NEXT_PUBLIC_ENABLE_EXPLORE_BANNER:-${ENABLE_EXPLORE_BANNER}}
export NEXT_PUBLIC_ENABLE_LEARN_APP=${NEXT_PUBLIC_ENABLE_LEARN_APP:-${ENABLE_LEARN_APP}}
export NEXT_PUBLIC_RBAC_ENABLED=${NEXT_PUBLIC_RBAC_ENABLED:-${RBAC_ENABLED}}
export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS}

View File

@ -87,6 +87,7 @@ const clientSchema = {
NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_TRIAL_APP: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_LEARN_APP: coercedBoolean.default(true),
NEXT_PUBLIC_RBAC_ENABLED: coercedBoolean.default(false),
/**
@ -217,6 +218,7 @@ export const env = createEnv({
NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'),
NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP : getRuntimeEnvFromBody('enableTrialApp'),
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER : getRuntimeEnvFromBody('enableExploreBanner'),
NEXT_PUBLIC_ENABLE_LEARN_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_LEARN_APP : getRuntimeEnvFromBody('enableLearnApp'),
NEXT_PUBLIC_RBAC_ENABLED: isServer ? process.env.NEXT_PUBLIC_RBAC_ENABLED : getRuntimeEnvFromBody('rbacEnabled'),
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'),

View File

@ -18,6 +18,7 @@ const defaultCloudEnv = {
NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: true,
NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: false,
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: true,
NEXT_PUBLIC_ENABLE_LEARN_APP: true,
NEXT_PUBLIC_ENABLE_MARKETPLACE: true,
NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: true,
NEXT_PUBLIC_ENABLE_TRIAL_APP: true,
@ -140,6 +141,7 @@ describe('systemFeaturesQueryOptions', () => {
enable_email_password_login: false,
enable_social_oauth_login: true,
enable_trial_app: true,
enable_learn_app: true,
rbac_enabled: false,
})
})
@ -153,6 +155,7 @@ describe('systemFeaturesQueryOptions', () => {
NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: true,
NEXT_PUBLIC_ALLOW_REGISTER: false,
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: false,
NEXT_PUBLIC_ENABLE_LEARN_APP: true,
NEXT_PUBLIC_RBAC_ENABLED: true,
},
})
@ -166,6 +169,7 @@ describe('systemFeaturesQueryOptions', () => {
enable_collaboration_mode: true,
is_allow_register: false,
enable_explore_banner: false,
enable_learn_app: true,
rbac_enabled: true,
branding: {
enabled: false,
@ -219,6 +223,7 @@ describe('serverSystemFeaturesQueryOptions', () => {
cloudEnv: {
NEXT_PUBLIC_ENABLE_MARKETPLACE: false,
NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true,
NEXT_PUBLIC_ENABLE_LEARN_APP: true,
},
})
@ -231,6 +236,7 @@ describe('serverSystemFeaturesQueryOptions', () => {
expect(data).toMatchObject({
enable_marketplace: false,
enable_email_password_login: true,
enable_learn_app: true,
})
})

View File

@ -52,6 +52,7 @@ export const defaultSystemFeatures = {
enable_creators_platform: false,
enable_trial_app: false,
enable_explore_banner: false,
enable_learn_app: true,
} satisfies GetSystemFeaturesResponse
export const cloudSystemFeatures = {
@ -101,5 +102,6 @@ export const cloudSystemFeatures = {
enable_creators_platform: env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED,
enable_trial_app: env.NEXT_PUBLIC_ENABLE_TRIAL_APP,
enable_explore_banner: env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER,
enable_learn_app: env.NEXT_PUBLIC_ENABLE_LEARN_APP,
rbac_enabled: env.NEXT_PUBLIC_RBAC_ENABLED,
} satisfies GetSystemFeaturesResponse