mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 23:01:11 +08:00
Merge remote-tracking branch 'origin/main' into feat/agent-v2
This commit is contained in:
commit
c5558b562c
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
@ -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),
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: ...
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
175
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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: '' }),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
128
cli/src/sys/io/reasoning.test.ts
Normal file
128
cli/src/sys/io/reasoning.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
99
cli/src/sys/io/reasoning.ts
Normal file
99
cli/src/sys/io/reasoning.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
120
cli/test/e2e/fixtures/apps/reasoning-chat.yml
Normal file
120
cli/test/e2e/fixtures/apps/reasoning-chat.yml
Normal 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: []
|
||||
@ -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,
|
||||
|
||||
@ -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]]
|
||||
: []),
|
||||
|
||||
91
cli/test/e2e/suites/run/run-app-reasoning.e2e.ts
Normal file
91
cli/test/e2e/suites/run/run-app-reasoning.e2e.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
2
cli/test/fixtures/dify-mock/scenarios.ts
vendored
2
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -15,6 +15,8 @@ export type Scenario
|
||||
| 'server-version-unsupported'
|
||||
| 'run-422-stale'
|
||||
| 'workflow-think'
|
||||
| 'chat-reasoning'
|
||||
| 'workflow-reasoning'
|
||||
| 'import-pending'
|
||||
| 'import-failed'
|
||||
|
||||
|
||||
26
cli/test/fixtures/dify-mock/server.ts
vendored
26
cli/test/fixtures/dify-mock/server.ts
vendored
@ -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' } })
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [] }
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -263,6 +263,7 @@ function List({
|
||||
onCreateBlank={openCreateBlankModal}
|
||||
onCreateTemplate={openCreateTemplateDialog}
|
||||
onImportDSL={openCreateFromDSLModal}
|
||||
showLearnDify={systemFeatures.enable_learn_app}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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} />
|
||||
|
||||
|
||||
@ -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' }))
|
||||
|
||||
|
||||
@ -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!" />
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -78,6 +78,8 @@ export const createRunningWorkflowState = () => {
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
reasoningContent: {},
|
||||
reasoningFinished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user