Compare commits

..

8 Commits

Author SHA1 Message Date
c367f77069 fix(workflow): tick the tagged reasoning timer in the run result panel
The run result panel renders tagged <think> blocks via Markdown but sits
outside ChatContext, so ThinkBlock read no isResponding and froze the timer at
0s. Provide isResponding (from isRunning) via ChatContextProvider.
2026-06-23 23:01:44 +08:00
b3154f1bbd feat(workflow): show separated-mode LLM reasoning in run preview
Workflow apps now stream LLM reasoning_content out of band like chatflow: the
workflow task pipeline emits reasoning_chunk events, and the run preview
accumulates them per LLM node and renders a thinking panel above the result.
2026-06-23 23:01:14 +08:00
fe1fb88c54 test(cli): add opt-in e2e suite for separated-mode reasoning
Live coverage for the reasoning_chunk adaptation, gated to skip by default:

- reasoning-chat.yml fixture: chatflow with an LLM node (reasoning_format:
  separated) whose system prompt forces a <think> block, so any chat model
  triggers the separated path — no dedicated reasoning model needed
- run-app-reasoning.e2e.ts: --think surfaces reasoning to stderr as <think>,
  answer stays clean, -o json persists metadata.reasoning, no --think hides it
- thread DIFY_E2E_REASONING_APP_ID through env.ts / global-setup capabilities;
  optionalIt skips the suite unless it resolves
- DIFY_E2E_REASONING_PROVISION=1 opts into auto-provisioning the fixture, so
  the shared bootstrap stays free of any model dependency by default
- document both vars in README + .env.e2e.example
2026-06-23 19:01:35 +08:00
ce0d67fd53 test(api): cover separated-mode reasoning streaming (#37460)
Backfill the test gap left by #37460's reasoning streaming:

- _handle_reasoning_chunk_event guard: non-empty emit, empty+non-final
  dropped, empty+final terminal marker emitted
- _handle_node_succeeded_event: LLM reasoning_content persisted into
  metadata.reasoning, accumulating across passes (not overwriting)
- workflow_app_runner: NodeRunReasoningChunkEvent translates to a
  published QueueReasoningChunkEvent
2026-06-23 18:45:17 +08:00
5d604b32dc feat(cli): surface separated-mode LLM reasoning in run/resume
PR #37460 added an out-of-band `reasoning_chunk` SSE channel for chatflow
apps whose LLM node uses reasoning_format=separated: the answer stream stays
free of <think> and the chain-of-thought streams on its own channel. difyctl
dropped these events, so `--think` showed nothing for such apps.

- new sys/io/reasoning module: parse reasoning_chunk payloads (nested under
  `data`), frame them to stderr identically to inline <think> blocks
- ChatStreamPrinter renders reasoning_chunk deltas to stderr under --think
- ChatCollector accumulates reasoning as a fallback; the server's persisted
  message_end metadata.reasoning stays the JSON source of truth
- streaming-structured echoes separated reasoning to stderr under --think
- update --think help to mention separated reasoning streams
2026-06-23 18:39:20 +08:00
ac06deba20 refactor(web): sync deployment route state from next route (#37811) 2026-06-23 09:49:25 +00:00
725e4da29d feat(chatflow): stream LLM reasoning to a live thinking panel (#37460)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 09:49:01 +00:00
c2a554da93 fix(api): disable gunicorn control sock (#37806) 2026-06-23 09:21:13 +00:00
133 changed files with 2787 additions and 1352 deletions

View File

@ -165,6 +165,7 @@ class SnippetDraftWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get draft workflow for snippet."""
snippet_service = _snippet_service()
@ -233,6 +234,7 @@ class SnippetDraftConfigApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get snippet draft workflow configuration limits."""
return {
@ -254,6 +256,7 @@ class SnippetPublishedWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get published workflow for snippet."""
if not snippet.is_published:
@ -318,6 +321,7 @@ class SnippetDefaultBlockConfigsApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get default block configurations for snippet workflow."""
snippet_service = _snippet_service()
@ -340,9 +344,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get all published workflow versions for snippet."""
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
@ -512,6 +514,9 @@ class SnippetDraftNodeRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a single node in snippet draft workflow.
@ -600,6 +605,9 @@ class SnippetDraftRunIterationNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow iteration node for snippet.
@ -645,6 +653,9 @@ class SnippetDraftRunLoopNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow loop node for snippet.
@ -688,6 +699,9 @@ class SnippetDraftWorkflowRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet):
"""
Run draft workflow for snippet.
@ -726,6 +740,9 @@ class SnippetWorkflowTaskStopApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, snippet: CustomizedSnippet, task_id: str):
"""
Stop a running snippet workflow task.

View File

@ -105,6 +105,7 @@ class SnippetWorkflowVariableCollectionApi(Resource):
)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_without_value_model)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
@ -157,9 +158,6 @@ class SnippetNodeVariableCollectionApi(Resource):
@console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)")
@console_ns.response(204, "Node variables deleted successfully")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> Response:
validate_node_id(node_id)
srv = WorkflowDraftVariableService(db.session())
@ -194,9 +192,6 @@ class SnippetVariableApi(Resource):
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_model)
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def patch(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {})
@ -244,9 +239,6 @@ class SnippetVariableApi(Resource):
@console_ns.response(204, "Variable deleted successfully")
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
variable = ensure_variable_access(
@ -269,9 +261,6 @@ class SnippetVariableResetApi(Resource):
@console_ns.response(204, "Variable reset (no content)")
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def put(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> Response | Any:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
snippet_service = _snippet_service()

View File

@ -4,17 +4,12 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.wraps import enforce_rbac_access
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
setup_required,
@ -23,10 +18,9 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import current_account_with_tenant, login_required
from libs.login import login_required
from models import Account
from models.enums import TagType
from models.model import Tag
from services.tag_service import (
SaveTagPayload,
TagBindingCreatePayload,
@ -97,30 +91,6 @@ register_schema_models(
register_response_schema_models(console_ns, SimpleResultResponse)
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
if tag_type != TagType.SNIPPET:
return
if not dify_config.RBAC_ENABLED:
return
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
if not dify_config.RBAC_ENABLED:
return
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id).limit(1))
_enforce_snippet_tag_rbac_if_needed(tag_type)
@console_ns.route("/tags")
class TagListApi(Resource):
@setup_required
@ -152,7 +122,6 @@ class TagListApi(Resource):
raise Forbidden()
payload = TagBasePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
response = TagResponse.model_validate(
@ -177,7 +146,6 @@ class TagUpdateDeleteApi(Resource):
raise Forbidden()
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
@ -196,7 +164,6 @@ class TagUpdateDeleteApi(Resource):
def delete(self, tag_id: UUID):
tag_id_str = str(tag_id)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
TagService.delete_tag(tag_id_str, db.session)
return "", 204
@ -217,7 +184,6 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.save_tag_binding(
TagBindingCreatePayload(
tag_ids=payload.tag_ids,
@ -233,7 +199,6 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.delete_tag_binding(
TagBindingDeletePayload(
tag_ids=payload.tag_ids,

View File

@ -455,6 +455,9 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
@with_current_tenant_id
def post(self, current_tenant_id: str, snippet_id: str):
"""Increment snippet use count when it is inserted into a workflow."""

View File

@ -31,6 +31,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@ -47,6 +48,7 @@ from core.app.entities.task_entities import (
MessageAudioEndStreamResponse,
MessageAudioStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
StreamResponse,
TextChunkStreamResponse,
WorkflowAppBlockingResponse,
@ -571,6 +573,27 @@ 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 out-of-band reasoning chunk events.
Pure emit: reasoning is streamed on its own channel and never written to the
workflow output. The terminal marker (is_final) may carry an empty reasoning
string, in which case it is still forwarded as the "thinking finished" signal.
Workflow runs have no message id, so it is omitted from the response.
"""
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 +623,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueuePingEvent: self._handle_ping_event,
QueueErrorEvent: self._handle_error_event,
QueueTextChunkEvent: self._handle_text_chunk_event,
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
# Workflow events
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,

View File

@ -743,7 +743,8 @@ class ReasoningChunkStreamResponse(StreamResponse):
Data entity
"""
message_id: str
# Optional: chat apps set the message id; workflow runs have no message and omit it.
message_id: str | None = None
reasoning: str
node_id: str | None = None
is_final: bool = False

View File

@ -185,8 +185,8 @@ class EnterpriseRequest(BaseRequest):
if (
not cls.rbac_base_url.startswith("http")
and not cls.rbac_base_url.startswith("https")
and not cls.rbac_base_url
or not cls.rbac_base_url.startswith("https")
or not cls.rbac_base_url
):
raise ValueError("ENTERPRISE_RBAC_API_URL is required when RBAC_ENABLED=true")

View File

@ -155,36 +155,6 @@ class TestTagListApi:
assert result["name"] == "test-tag"
assert result["binding_count"] == "0"
def test_post_snippet_tag_checks_snippet_rbac_when_enabled(self, app: Flask, admin_user, tag, payload_patch):
api = TagListApi()
method = unwrap(api.post)
payload = {"name": "snippet-tag", "type": "snippet"}
with app.test_request_context("/", json=payload):
with (
payload_patch(payload),
patch("controllers.console.tag.tags.dify_config.RBAC_ENABLED", True),
patch(
"controllers.console.tag.tags.current_account_with_tenant",
return_value=(SimpleNamespace(id="user-1"), "tenant-1"),
),
patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock,
patch(
"controllers.console.tag.tags.TagService.save_tags",
return_value=tag,
),
):
method(api, admin_user)
enforce_mock.assert_called_once_with(
tenant_id="tenant-1",
account_id="user-1",
resource_type=module.RBACResourceScope.WORKSPACE,
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def test_post_forbidden(self, app: Flask, readonly_user, payload_patch):
api = TagListApi()
method = unwrap(api.post)

View File

@ -29,6 +29,7 @@ from core.app.entities.queue_entities import (
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueTextChunkEvent,
@ -46,6 +47,7 @@ from core.app.entities.task_entities import (
MessageAudioStreamResponse,
MessageEndStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
)
from core.base.tts.app_generator_tts_publisher import AudioTrunk
from core.workflow.system_variables import build_system_variables
@ -196,6 +198,42 @@ class TestAdvancedChatGenerateTaskPipeline:
assert pipeline._task_state.answer == "hi"
assert responses
def test_handle_reasoning_chunk_event_emits_on_nonempty(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
assert response.data.message_id == pipeline._message_id
assert response.data.reasoning == "pondering"
assert response.data.node_id == "llm-1"
assert response.data.is_final is False
# reasoning never touches the answer stream
assert pipeline._task_state.answer == ""
def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert responses == []
def test_handle_reasoning_chunk_event_emits_empty_final_marker(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
assert response.data.reasoning == ""
assert response.data.is_final is True
def test_listen_audio_msg_returns_audio_stream(self):
pipeline = _make_pipeline()
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
@ -319,6 +357,43 @@ class TestAdvancedChatGenerateTaskPipeline:
assert responses == ["done"]
assert pipeline._recorded_files
def test_handle_node_succeeded_event_records_llm_reasoning(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: []
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
pipeline._save_output_for_event = lambda event, node_execution_id: None
event = SimpleNamespace(
node_type=BuiltinNodeTypes.LLM,
outputs={"reasoning_content": "first pass "},
node_execution_id="exec",
node_id="llm-1",
)
list(pipeline._handle_node_succeeded_event(event))
assert pipeline._task_state.metadata.reasoning == {"llm-1": "first pass "}
def test_handle_node_succeeded_event_accumulates_reasoning_across_passes(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: []
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
pipeline._save_output_for_event = lambda event, node_execution_id: None
def _llm_event(reasoning: str):
return SimpleNamespace(
node_type=BuiltinNodeTypes.LLM,
outputs={"reasoning_content": reasoning},
node_execution_id="exec",
node_id="llm-1",
)
# Same node id across iteration/loop passes must accumulate, not overwrite.
list(pipeline._handle_node_succeeded_event(_llm_event("pass one ")))
list(pipeline._handle_node_succeeded_event(_llm_event("pass two")))
assert pipeline._task_state.metadata.reasoning == {"llm-1": "pass one pass two"}
def test_iteration_and_loop_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_run_id = "run-id"

View File

@ -16,6 +16,7 @@ from core.app.entities.queue_entities import (
QueueNodeFailedEvent,
QueueNodeRetryEvent,
QueueNodeSucceededEvent,
QueueReasoningChunkEvent,
QueueTextChunkEvent,
QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
@ -34,6 +35,7 @@ from graphon.graph_events import (
NodeRunHumanInputFormFilledEvent,
NodeRunIterationSucceededEvent,
NodeRunLoopFailedEvent,
NodeRunReasoningChunkEvent,
NodeRunRetryEvent,
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
@ -395,6 +397,17 @@ class TestWorkflowBasedAppRunner:
is_final=False,
),
)
runner._handle_event(
workflow_entry,
NodeRunReasoningChunkEvent(
id="exec",
node_id="node",
node_type=BuiltinNodeTypes.LLM,
selector=["node", "reasoning_content"],
chunk="thinking",
is_final=False,
),
)
runner._handle_event(
workflow_entry,
NodeRunAgentLogEvent(
@ -442,6 +455,7 @@ class TestWorkflowBasedAppRunner:
)
assert any(isinstance(event, QueueTextChunkEvent) for event in published)
assert any(isinstance(event, QueueReasoningChunkEvent) for event in published)
assert any(isinstance(event, QueueAgentLogEvent) for event in published)
assert any(isinstance(event, QueueIterationCompletedEvent) for event in published)
assert any(isinstance(event, QueueLoopCompletedEvent) for event in published)

View File

@ -26,6 +26,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@ -40,6 +41,7 @@ from core.app.entities.task_entities import (
MessageAudioEndStreamResponse,
MessageAudioStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
WorkflowAppPausedBlockingResponse,
WorkflowFinishStreamResponse,
WorkflowStartStreamResponse,
@ -265,6 +267,41 @@ class TestWorkflowGenerateTaskPipeline:
assert responses[0].data.text == "hi"
assert published == [queue_message]
def test_handle_reasoning_chunk_event_emits_on_nonempty(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
# workflow runs have no message, so the id is omitted
assert response.data.message_id is None
assert response.data.reasoning == "pondering"
assert response.data.node_id == "llm-1"
assert response.data.is_final is False
def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert responses == []
def test_handle_reasoning_chunk_event_emits_empty_final_marker(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
assert response.data.reasoning == ""
assert response.data.is_final is True
def test_dispatch_event_handles_node_failed(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"

View File

@ -29,7 +29,7 @@ export default class ResumeApp extends DifyCommand {
'workspace': Flags.string({ description: 'workspace id override' }),
'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }),
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline <think>...</think> blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'http-retry': httpRetryFlag,
}

View File

@ -7,6 +7,7 @@ import { collect, HitlPauseError } from '@/commands/run/app/sse-collector'
import { formatted, stringifyOutput } from '@/framework/output'
import { handle, unhandle } from '@/sys/index'
import { colorEnabled, colorScheme } from '@/sys/io/color'
import { reasoningBlocksFromMetadata } from '@/sys/io/reasoning'
import { startSpinner } from '@/sys/io/spinner'
import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks } from '@/sys/io/think-filter'
@ -99,6 +100,15 @@ export class StreamingStructuredStrategy implements RunStrategy {
}
}
// Separated-mode reasoning rides in message_end metadata, not the answer, so
// surface it to stderr under --think the same way inline <think> is above. It
// stays in the JSON envelope's metadata (the server's persisted copy).
if (ctx.think) {
const reasoningBlocks = reasoningBlocksFromMetadata(processedResp.metadata)
if (reasoningBlocks !== '')
deps.io.err.write(`${reasoningBlocks}\n`)
}
const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode
deps.io.out.write(stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) })))
if (isText && CHAT_MODES.has(respMode)) {

View File

@ -35,7 +35,7 @@ export default class RunApp extends DifyCommand {
'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }),
'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline <think>...</think> blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }),
'retry-on-limit': Flags.boolean({ description: 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', default: false }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),

View File

@ -203,6 +203,46 @@ 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-error scenario: error event surfaces typed BaseError', async () => {
mock.setScenario('stream-error')
const io = bufferStreams()

View File

@ -59,6 +59,41 @@ describe('collect — chat', () => {
})
})
describe('collect — chat separated reasoning', () => {
function reasoningEvent(reasoning: string, isFinal: boolean) {
return ev('reasoning_chunk', { data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal } })
}
it('backfills metadata.reasoning from live deltas when the server omits it', async () => {
const got = await collect(iterOf(
reasoningEvent('pon', false),
reasoningEvent('dering', true),
ev('message', { message_id: 'm1', answer: 'answer' }),
ev('message_end', { metadata: { usage: { tokens: 3 } } }),
), 'advanced-chat')
expect(got.answer).toBe('answer')
expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' })
expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 3 })
})
it('keeps the server-persisted reasoning over live deltas', async () => {
const got = await collect(iterOf(
reasoningEvent('live', true),
ev('message', { answer: 'a' }),
ev('message_end', { metadata: { reasoning: { 'llm-1': 'persisted' } } }),
), 'advanced-chat')
expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'persisted' })
})
it('leaves metadata untouched when there is no reasoning at all', async () => {
const got = await collect(iterOf(
ev('message', { answer: 'a' }),
ev('message_end', { metadata: { usage: { tokens: 1 } } }),
), 'advanced-chat')
expect((got.metadata as { reasoning?: unknown }).reasoning).toBeUndefined()
})
})
describe('collect — agent-chat', () => {
it('captures agent_thoughts', async () => {
const got = await collect(iterOf(

View File

@ -2,6 +2,7 @@ import type { BaseError } from '@/errors/base'
import type { SseEvent } from '@/http/sse'
import { HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { 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,15 @@ class ChatCollector implements Collector {
copyScalar(this.base, c, ['id', 'conversation_id', 'message_id', 'task_id', 'created_at'])
return
}
// Accumulate out-of-band (separated-mode) reasoning deltas per LLM node.
case 'reasoning_chunk': {
const chunk = parseReasoningChunk(c)
if (chunk !== undefined && chunk.reasoning !== '') {
const key = chunk.nodeId !== '' ? chunk.nodeId : '_'
this.reasoning[key] = (this.reasoning[key] ?? '') + chunk.reasoning
}
return
}
case 'agent_thought':
this.thoughts.push(c)
return
@ -98,12 +109,24 @@ 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
// The server persists terminal reasoning into message_end metadata; fall back
// to the live deltas only when that authoritative copy is absent.
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> = {}

View File

@ -37,6 +37,42 @@ describe('streamPrinterFor — chat', () => {
})
})
function reasoningEvent(reasoning: string, isFinal: boolean) {
return ev('reasoning_chunk', { data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal } })
}
describe('streamPrinterFor — chat separated reasoning', () => {
it('think: true frames reasoning_chunk deltas to stderr, answer stays clean on stdout', () => {
const sp = streamPrinterFor('advanced-chat', true)
const cap = captures()
sp.onEvent(cap.out, cap.err, reasoningEvent('pon', false))
sp.onEvent(cap.out, cap.err, reasoningEvent('dering', false))
sp.onEvent(cap.out, cap.err, reasoningEvent('', true))
sp.onEvent(cap.out, cap.err, ev('message', { conversation_id: 'c1', answer: 'final answer' }))
sp.onEnd(cap.out, cap.err)
expect(cap.errBuf()).toContain('<think>\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>\nthinking</think>')
})
})
describe('streamPrinterFor — agent-chat', () => {
it('writes agent_thought to stderr', () => {
const sp = streamPrinterFor('agent-chat')

View File

@ -4,6 +4,7 @@ import type { SseEvent } from '@/http/sse'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { colorEnabled, colorScheme } from '@/sys/io/color'
import { parseReasoningChunk, ReasoningChunkRenderer } from '@/sys/io/reasoning'
import { filterThinkInOutputs, ThinkChunkFilter } from '@/sys/io/think-filter'
import { RUN_MODES } from './handlers'
import { HitlPauseError } from './sse-collector'
@ -43,9 +44,12 @@ function handleCommonEvents(ev: SseEvent): boolean {
class ChatStreamPrinter implements StreamPrinter {
private convoId = ''
private readonly filter: ThinkChunkFilter
private readonly reasoning = new ReasoningChunkRenderer()
private readonly think: boolean
private readonly isTTY: boolean
constructor(think: boolean, isTTY = false) {
this.filter = new ThinkChunkFilter(think)
this.think = think
this.isTTY = isTTY
}
@ -62,6 +66,16 @@ class ChatStreamPrinter implements StreamPrinter {
this.convoId = c.conversation_id
return
}
// Separated-mode reasoning: stream the out-of-band chain-of-thought to
// stderr under --think, mirroring how inline <think> blocks are surfaced.
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 +87,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 !== '') {

View File

@ -0,0 +1,92 @@
import { Buffer } from 'node:buffer'
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import {
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 <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>\npondering</think>\n')
})
it('emits separate 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>\na</think>\n<think>\nb</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>\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('formatReasoningBlocks', () => {
it('frames and trims each node, joined by a separator', () => {
expect(formatReasoningBlocks({ n1: ' one ', n2: 'two' }))
.toBe('<think>\none\n</think>\n---\n<think>\ntwo\n</think>')
})
it('skips empty entries and returns empty for no reasoning', () => {
expect(formatReasoningBlocks({ n1: ' ' })).toBe('')
expect(formatReasoningBlocks({})).toBe('')
})
})
describe('reasoningBlocksFromMetadata', () => {
it('extracts reasoning from a metadata object', () => {
expect(reasoningBlocksFromMetadata({ reasoning: { n1: 'why' } }))
.toBe('<think>\nwhy\n</think>')
})
it('returns empty for tagged mode (empty reasoning) and malformed input', () => {
expect(reasoningBlocksFromMetadata({ reasoning: {} })).toBe('')
expect(reasoningBlocksFromMetadata(undefined)).toBe('')
expect(reasoningBlocksFromMetadata({ usage: { tokens: 1 } })).toBe('')
})
})

View File

@ -0,0 +1,91 @@
// Out-of-band reasoning ("separated" mode). When an LLM node sets
// reasoning_format=separated, the server keeps the answer stream free of
// <think> blocks and streams the chain-of-thought on its own `reasoning_chunk`
// SSE channel instead. This module renders that channel to stderr so --think
// looks identical whether reasoning arrives inline (tagged) or out-of-band
// (separated) — see think-filter.ts for the inline counterpart.
const THINK_OPEN = '<think>'
const THINK_CLOSE = '</think>'
export type ReasoningChunk = {
reasoning: string
nodeId: string
isFinal: boolean
}
// A `reasoning_chunk` event nests its payload under `data` (unlike `message`
// events, whose `answer` sits at the top level). Returns undefined when the
// event carries no reasoning payload.
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,
}
}
// Incrementally frames a separated reasoning stream into stderr the same way
// ThinkChunkFilter frames inline <think> blocks: `<think>\n` on the first delta,
// raw deltas thereafter, `</think>\n` on the terminal marker. Each LLM node's
// stream ends with is_final, so multiple nodes produce separate framed blocks.
export class ReasoningChunkRenderer {
private open = false
push(chunk: ReasoningChunk, errOut: NodeJS.WritableStream): void {
if (chunk.reasoning !== '') {
if (!this.open) {
errOut.write(`${THINK_OPEN}\n`)
this.open = true
}
errOut.write(chunk.reasoning)
}
if (chunk.isFinal)
this.close(errOut)
}
// Close a block left open by a truncated stream (no terminal marker arrived).
flush(errOut: NodeJS.WritableStream): void {
this.close(errOut)
}
private close(errOut: NodeJS.WritableStream): void {
if (!this.open)
return
errOut.write(`${THINK_CLOSE}\n`)
this.open = false
}
}
// Renders fully-buffered reasoning (one entry per LLM node id, as persisted in
// message_end metadata) into <think>-framed blocks, mirroring extractThinkBlocks.
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')
}
// Pulls per-node reasoning out of a message_end `metadata` object and frames it.
// Returns '' when metadata carries no (non-empty) reasoning — e.g. tagged mode,
// where the server sends `reasoning: {}`.
export function reasoningBlocksFromMetadata(metadata: unknown): string {
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata))
return ''
const reasoning = (metadata as Record<string, unknown>).reasoning
if (reasoning === null || typeof reasoning !== 'object' || Array.isArray(reasoning))
return ''
const map: Record<string, string> = {}
for (const [key, value] of Object.entries(reasoning as Record<string, unknown>)) {
if (typeof value === 'string')
map[key] = value
}
return formatReasoningBlocks(map)
}

View File

@ -67,3 +67,15 @@ DIFY_E2E_PASSWORD=
# DIFY_E2E_HITL_SINGLE_ACTION_APP_ID=
# DIFY_E2E_HITL_MULTI_NODE_APP_ID=
# DIFY_E2E_WS2_APP_ID=
# ── Separated-mode reasoning suite (opt-in) ─────────────────────────────────
# run-app-reasoning.e2e.ts is skipped unless DIFY_E2E_REASONING_APP_ID resolves.
# It needs a chatflow whose LLM node uses reasoning_format=separated AND a
# workspace with a default chat model configured.
#
# Either point at an existing app:
# DIFY_E2E_REASONING_APP_ID=
#
# …or auto-provision reasoning-chat.yml (→ app name "reasoning-bot"). Off by
# default so the shared bootstrap stays free of any model dependency.
# DIFY_E2E_REASONING_PROVISION=1

View File

@ -39,11 +39,12 @@ test/e2e/
│ ├── describe-app.e2e.ts — describe app
│ └── get-app-all-workspaces.e2e.ts — get app -A ([EE] multi-workspace cases)
└── run/
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
│ conversation, CI mode
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
├── run-app-file.e2e.ts — --file upload (local + remote URL)
── run-app-hitl.e2e.ts — HITL pause + resume
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
conversation, CI mode
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
├── run-app-file.e2e.ts — --file upload (local + remote URL)
── run-app-reasoning.e2e.ts — separated-mode reasoning (--think); opt-in
└── run-app-hitl.e2e.ts — HITL pause + resume
```
## Edition support
@ -137,6 +138,24 @@ global-setup will:
| `DIFY_E2E_HITL_SINGLE_ACTION_APP_ID` | |
| `DIFY_E2E_HITL_MULTI_NODE_APP_ID` | |
| `DIFY_E2E_WS2_APP_ID` | Override secondary workspace app ID (EE) |
| `DIFY_E2E_REASONING_APP_ID` | separated-reasoning chatflow app ID (opt-in) |
| `DIFY_E2E_REASONING_PROVISION` | `1` → auto-provision `reasoning-chat.yml` |
### Separated-mode reasoning suite (opt-in)
`run-app-reasoning.e2e.ts` verifies the out-of-band `reasoning_chunk` channel
(PR #37460): `--think` surfaces the chain-of-thought to stderr framed as
`<think>…</think>`, the answer stays clean, and `-o json` persists it under
`metadata.reasoning`. It is **skipped** unless `DIFY_E2E_REASONING_APP_ID`
resolves, because it runs a real LLM node and needs:
1. a chatflow whose LLM node uses `reasoning_format: separated`, and
1. a workspace with a default chat model configured.
Point `DIFY_E2E_REASONING_APP_ID` at such an app, or set
`DIFY_E2E_REASONING_PROVISION=1` to import the `reasoning-chat.yml` fixture
(its system prompt forces a `<think>` block, so any chat model triggers the
separated path — no dedicated reasoning model required).
## Running tests

View File

@ -0,0 +1,120 @@
# Chatflow that exercises separated-mode reasoning (PR #37460): the LLM node sets
# reasoning_format=separated, so the server strips <think>...</think> from the
# answer and streams the chain-of-thought on the out-of-band `reasoning_chunk`
# channel instead. The system prompt forces a <think> block, so the separated
# path triggers with any chat model — no dedicated reasoning model required.
#
# NOTE: the LLM node leaves model.provider/name empty and relies on the target
# workspace's configured default chat model. The run-app-reasoning E2E suite is
# gated on DIFY_E2E_REASONING_APP_ID, so it is skipped unless a server with a
# working model is wired up.
app:
description: e2e-test reasoning (separated mode)
icon: 🧠
icon_background: '#FFEAD5'
icon_type: emoji
mode: advanced-chat
name: reasoning-bot
use_icon_as_answer_icon: false
dependencies: []
kind: app
version: 0.6.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload: {}
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- id: start-llm
source: '1755189262236'
sourceHandle: source
target: llm
targetHandle: target
- id: llm-answer
source: llm
sourceHandle: source
target: answer
targetHandle: target
nodes:
- data:
desc: ''
title: Start
type: start
variables: []
id: '1755189262236'
position:
x: 80
y: 282
sourcePosition: right
targetPosition: left
type: custom
- data:
context:
enabled: false
variable_selector: []
desc: ''
memory:
query_prompt_template: '{{#sys.query#}}'
window:
enabled: false
size: 10
model:
completion_params:
temperature: 0.7
mode: chat
name: ''
provider: ''
prompt_template:
- role: system
text: >-
You are a helpful assistant. Always reason step by step INSIDE a
single <think>...</think> block first, then write the final
answer AFTER the closing </think> tag. The final answer must not
contain any <think> tags.
reasoning_format: separated
selected: false
title: LLM
type: llm
variables: []
vision:
enabled: false
id: llm
position:
x: 380
y: 282
sourcePosition: right
targetPosition: left
type: custom
- data:
answer: '{{#llm.text#}}'
desc: ''
title: Answer
type: answer
variables: []
id: answer
position:
x: 680
y: 282
sourcePosition: right
targetPosition: left
type: custom
viewport:
x: 0
y: 0
zoom: 1
rag_pipeline_variables: []

View File

@ -37,6 +37,9 @@
* DIFY_E2E_HITL_EXTERNAL_APP_ID
* DIFY_E2E_HITL_SINGLE_ACTION_APP_ID
* DIFY_E2E_HITL_MULTI_NODE_APP_ID
* DIFY_E2E_REASONING_APP_ID Override separated-reasoning chatflow app ID
* DIFY_E2E_REASONING_PROVISION=1 Opt in to auto-provisioning reasoning-chat.yml
* (needs a workspace default chat model)
*/
/** Supported edition values. */
@ -74,6 +77,12 @@ export type E2EEnv = {
fileAppId: string
/** Chat app (advanced-chat) with a file input variable */
fileChatAppId: string
/**
* Chatflow whose LLM node uses reasoning_format=separated. Empty unless
* DIFY_E2E_REASONING_APP_ID is set or the fixture is auto-provisioned; the
* run-app-reasoning suite is skipped when empty.
*/
reasoningAppId: string
/**
* Secondary workspace ID — EE only ("auto_test1").
* Empty in CE mode (CE has a single workspace).
@ -118,6 +127,7 @@ export type E2ECapabilities = {
workflowAppId: string
fileAppId: string
fileChatAppId: string
reasoningAppId: string
hitlAppId: string
hitlExternalAppId: string
hitlSingleActionAppId: string
@ -171,6 +181,7 @@ export function loadE2EEnv(): E2EEnv {
hitlMultiNodeAppId: process.env.DIFY_E2E_HITL_MULTI_NODE_APP_ID ?? '',
fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '',
fileChatAppId: process.env.DIFY_E2E_FILE_CHAT_APP_ID ?? '',
reasoningAppId: process.env.DIFY_E2E_REASONING_APP_ID ?? '',
ws2Id: process.env.DIFY_E2E_WS2_ID ?? '',
ws2AppId: process.env.DIFY_E2E_WS2_APP_ID ?? '',
email: process.env.DIFY_E2E_EMAIL!,
@ -206,6 +217,7 @@ export function resolveEnv(caps: E2ECapabilities | undefined): E2EEnv {
workflowAppId: caps.workflowAppId || env.workflowAppId,
fileAppId: caps.fileAppId || env.fileAppId,
fileChatAppId: caps.fileChatAppId || env.fileChatAppId,
reasoningAppId: caps.reasoningAppId || env.reasoningAppId,
hitlAppId: caps.hitlAppId || env.hitlAppId,
hitlExternalAppId: caps.hitlExternalAppId || env.hitlExternalAppId,
hitlSingleActionAppId: caps.hitlSingleActionAppId || env.hitlSingleActionAppId,

View File

@ -182,6 +182,7 @@ export async function setup(project: TestProject): Promise<void> {
workflowAppId: '',
fileAppId: '',
fileChatAppId: '',
reasoningAppId: '',
hitlAppId: '',
hitlExternalAppId: '',
hitlSingleActionAppId: '',
@ -288,6 +289,7 @@ export async function setup(project: TestProject): Promise<void> {
workflowAppId: provisionedIds.DIFY_E2E_WORKFLOW_APP_ID || E.workflowAppId,
fileAppId: provisionedIds.DIFY_E2E_FILE_APP_ID || E.fileAppId,
fileChatAppId: provisionedIds.DIFY_E2E_FILE_CHAT_APP_ID || E.fileChatAppId,
reasoningAppId: provisionedIds.DIFY_E2E_REASONING_APP_ID || E.reasoningAppId,
hitlAppId: provisionedIds.DIFY_E2E_HITL_APP_ID || E.hitlAppId,
hitlExternalAppId: provisionedIds.DIFY_E2E_HITL_EXTERNAL_APP_ID || E.hitlExternalAppId,
hitlSingleActionAppId: provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId,
@ -503,6 +505,12 @@ async function provisionApps(
['hitl-single-action.yml', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', primaryWsId],
['hitl-multi-node.yml', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', primaryWsId],
['file-chat.yml', 'DIFY_E2E_FILE_CHAT_APP_ID', primaryWsId],
// reasoning-chat.yml runs a real LLM node, so it is opt-in: provisioning it
// requires the workspace to have a default chat model configured. Off by
// default to keep the shared bootstrap free of any model dependency.
...(process.env.DIFY_E2E_REASONING_PROVISION === '1'
? [['reasoning-chat.yml', 'DIFY_E2E_REASONING_APP_ID', primaryWsId] as [string, string, string]]
: []),
...(edition === 'ee'
? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]]
: []),

View File

@ -0,0 +1,91 @@
/**
* E2E: difyctl run app — separated-mode reasoning (PR #37460)
*
* Exercises the out-of-band `reasoning_chunk` SSE channel against a real server.
* Requires a chatflow whose LLM node uses reasoning_format=separated AND a
* workspace with a configured chat model. The whole suite is skipped unless
* DIFY_E2E_REASONING_APP_ID resolves (set it directly, or provision the
* reasoning-chat.yml fixture with DIFY_E2E_REASONING_PROVISION=1).
*
* Verifies the client adaptation:
* - --think surfaces the separated reasoning to stderr, framed as <think>…</think>
* - the answer (stdout) stays free of <think>
* - -o json persists the reasoning under metadata.reasoning
* - without --think, reasoning stays hidden
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { afterEach, beforeEach, describe, expect, inject } from 'vitest'
import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js'
import { registerConversation } from '../../helpers/cleanup-registry.js'
import { withAuthFixture } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { optionalIt } from '../../helpers/skip.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
// Skipped unless a separated-reasoning chatflow is wired up (needs a real model).
const reasoningIt = optionalIt(Boolean(E.reasoningAppId))
const QUERY = 'In one short sentence, why is the sky blue?'
describe('E2E / difyctl run app — separated reasoning', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
reasoningIt('[P1] --think --stream surfaces reasoning on stderr, clean answer on stdout', async () => {
const result = await withRetry(
() => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '--stream']),
{ attempts: 3, delayMs: 1000 },
)
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
// Separated mode keeps the answer free of <think>; reasoning is framed on stderr.
expect(result.stdout).not.toContain('<think>')
assertStderrContains(result, '<think>')
})
reasoningIt('[P1] --think -o json persists reasoning under metadata.reasoning', async () => {
const result = await withRetry(
() => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '-o', 'json']),
{ attempts: 3, delayMs: 1000 },
)
assertExitCode(result, 0)
const parsed = assertJson<{
conversation_id?: string
answer: string
metadata?: { reasoning?: Record<string, string> }
}>(result)
if (parsed.conversation_id)
registerConversation(E.host, E.token, E.reasoningAppId, parsed.conversation_id)
const reasoning = parsed.metadata?.reasoning ?? {}
expect(Object.keys(reasoning).length).toBeGreaterThan(0)
expect(Object.values(reasoning).join('').length).toBeGreaterThan(0)
// --think also echoes the separated reasoning to stderr.
assertStderrContains(result, '<think>')
})
reasoningIt('[P1] without --think, reasoning stays hidden', async () => {
const result = await withRetry(
() => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--stream']),
{ attempts: 3, delayMs: 1000 },
)
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
expect(result.stderr).not.toContain('<think>')
})
})

View File

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

View File

@ -370,6 +370,18 @@ 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' } })
}
const sse = streamingRunResponse(app.mode, query, isAgent)
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } })
})

View File

@ -421,18 +421,16 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render sort filter before search and the snippets link', () => {
it('should render sort filter before search and hide the snippets link', () => {
renderList()
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
expect(snippetsLink).toHaveAttribute('href', '/snippets')
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {

View File

@ -8,7 +8,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import Link from '@/next/link'
import { AppSortFilter } from './app-sort-filter'
import { AppTypeFilter } from './app-type-filter'
import CreatorsFilter from './creators-filter'
@ -71,12 +70,6 @@ export function AppListHeaderFilters({
/>
</div>
<div className="flex items-center gap-2">
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
{showCreateButton && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger

View File

@ -5,7 +5,6 @@ import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { IWorkspace } from '@/models/common'
import type { InstalledApp } from '@/models/explore'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { createTestQueryClient, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
@ -17,7 +16,6 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { PipelineInputVarType } from '@/models/pipeline'
import { usePathname, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
@ -27,29 +25,14 @@ import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
const activeEdgeClassName = 'before:pointer-events-none'
type SnippetNavigationTestState = {
onFieldsChange?: (fields: SnippetInputField[]) => void
readonly: boolean
snippet?: SnippetDetail
}
const { mockIsAgentV2Enabled, mockSnippetFieldsChange, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations, snippetDraftState, snippetNavigationState } = vi.hoisted(() => ({
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations } = vi.hoisted(() => ({
mockSwitchWorkspace: vi.fn(),
mockSnippetFieldsChange: vi.fn(),
mockToastSuccess: vi.fn(),
mockIsAgentV2Enabled: vi.fn(() => true),
hotkeyRegistrations: new Map<string, {
handler: (event: { preventDefault: () => void }) => void
options?: { ignoreInputs?: boolean }
}>(),
snippetDraftState: {
inputFields: [],
} as { inputFields: SnippetInputField[] },
snippetNavigationState: {
readonly: true,
snippet: undefined,
onFieldsChange: undefined,
} as SnippetNavigationTestState,
}))
vi.mock('@/features/agent-v2/feature-flag', () => ({
@ -201,42 +184,6 @@ vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({
),
}))
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: SnippetNavigationTestState) => unknown) => selector(snippetNavigationState),
}))
vi.mock('@/app/components/snippets/draft-store', () => ({
useSnippetDraftStore: (selector: (state: typeof snippetDraftState) => unknown) => selector(snippetDraftState),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
SnippetSidebarContent: ({
fields,
onFieldsChange,
readonly,
snippet,
}: {
fields: SnippetInputField[]
onFieldsChange: (fields: SnippetInputField[]) => void
readonly: boolean
snippet: SnippetDetail
}) => (
<div data-testid="snippet-sidebar-content" data-readonly={String(readonly)}>
<span>{snippet.name}</span>
<span>{fields.map(field => field.variable).join(',')}</span>
<button type="button" onClick={() => onFieldsChange([])}>change snippet fields</button>
</div>
),
}))
vi.mock('../components/snippet-detail-top', () => ({
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="snippet-detail-top" data-expand={expand}>
<button type="button" data-testid="snippet-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
@ -296,24 +243,6 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
},
})
const snippet: SnippetDetail = {
id: 'snippet-1',
name: 'Snippet',
description: 'Description',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
}
const snippetFields: SnippetInputField[] = [
{
label: 'Query',
variable: 'query',
type: PipelineInputVarType.textInput,
required: true,
},
]
const appContextValue: AppContextValue = {
userProfile: {
id: 'user-1',
@ -443,10 +372,6 @@ describe('MainNav', () => {
})
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
hotkeyRegistrations.clear()
snippetDraftState.inputFields = []
snippetNavigationState.onFieldsChange = undefined
snippetNavigationState.readonly = true
snippetNavigationState.snippet = undefined
useAppStore.getState().setAppDetail()
})
@ -658,24 +583,12 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
it('replaces global navigation with snippet detail navigation on snippet routes', () => {
it('hides the main menu on snippet detail routes while keeping account settings available', () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
snippetNavigationState.readonly = false
snippetNavigationState.snippet = snippet
renderMainNav()
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('snippet-sidebar-content')).toHaveAttribute('data-readonly', 'false')
expect(screen.getByText(snippet.name)).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'change snippet fields' }))
expect(mockSnippetFieldsChange).toHaveBeenCalledWith([])
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.queryByLabelText('Dify')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
@ -685,24 +598,6 @@ describe('MainNav', () => {
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
})
it('collapses snippet detail navigation from the top-right toggle', () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
snippetNavigationState.snippet = snippet
renderMainNav()
fireEvent.click(screen.getByTestId('snippet-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.queryByTestId('snippet-sidebar-content')).not.toBeInTheDocument()
expect(screen.getByLabelText('Snippet collapsed preview')).toBeInTheDocument()
expect(screen.getByLabelText('1 input fields')).toBeInTheDocument()
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('replaces global navigation with app detail navigation on app routes', () => {
mockPathname = '/app/app-1/overview'

View File

@ -1,107 +0,0 @@
'use client'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import ToggleButton from '../../app-sidebar/toggle-button'
type SnippetDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
const SEARCH_SHORTCUT = ['Mod', 'K']
const SnippetDetailTop = ({
expand = true,
onToggle,
}: SnippetDetailTopProps) => {
const { t } = useTranslation()
const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
expand={expand}
handleToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
)}
</div>
)
}
return (
<div className="flex items-center py-2 pr-2 pl-1">
<div className="flex min-w-0 flex-1 items-center gap-px">
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
<button
type="button"
aria-label={t('operation.back', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
onClick={() => router.back()}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
</button>
<Link
href="/"
aria-label={t('mainNav.home', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
>
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
</div>
<span className="shrink-0 system-md-regular text-text-quaternary">
/
</span>
<Link
href="/snippets"
className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary"
>
{t('tabs.snippets', { ns: 'workflow' })}
</Link>
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('gotoAnything.searchTitle', { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
)}
/>
<TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]">
<span className="px-0.5">{t('gotoAnything.quickAction', { ns: 'app' })}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map(key => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
{onToggle && (
<ToggleButton
expand={expand}
handleToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
)}
</div>
)
}
export default SnippetDetailTop

View File

@ -14,10 +14,6 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
import { useStore as useAppStore } from '@/app/components/app/store'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import { SnippetCollapsedPreview } from '@/app/components/snippets/components/snippet-collapsed-preview'
import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
import { useAppContext } from '@/context/app-context'
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
@ -29,7 +25,6 @@ import AccountSection from './components/account-section'
import HelpMenu from './components/help-menu'
import MainNavLink from './components/nav-link'
import { MainNavSearchButton } from './components/search-button'
import SnippetDetailTop from './components/snippet-detail-top'
import WebAppsSection from './components/web-apps-section'
import { WorkspaceCard } from './components/workspace-card'
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
@ -100,14 +95,8 @@ const MainNav = ({
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
const showAgentDetailNavigation = agentV2Enabled && !isCurrentWorkspaceDatasetOperator && isAgentDetailPathname(pathname)
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
const showSnippetDetailNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation || showSnippetDetailNavigation
const snippetNavigation = useSnippetDetailStore(useShallow(state => ({
onFieldsChange: state.onFieldsChange,
readonly: state.readonly,
snippet: state.snippet,
})))
const snippetInputFields = useSnippetDraftStore(state => state.inputFields)
const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
setAppDetail: state.setAppDetail,
@ -122,7 +111,9 @@ const MainNav = ({
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
? false
: !showDetailNavigation || detailNavigationVisibleExpanded
const handleToggleDetailNavigation = useCallback(() => {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
@ -232,7 +223,9 @@ const MainNav = ({
? detailNavigationExpanded
? 'w-[248px] bg-background-body p-1'
: 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
: showSnippetDetailBottomNavigation
? 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
'bg-background-body',
className,
)}
@ -273,30 +266,25 @@ const MainNav = ({
onToggle={handleToggleDetailNavigation}
/>
)
: showDeploymentDetailNavigation
? (
<DeploymentDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: (
<SnippetDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: (
<>
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
{renderLogo()}
<MainNavSearchButton />
</div>
<div className="p-2">
<WorkspaceCard />
</div>
</>
)}
: (
<DeploymentDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: showSnippetDetailBottomNavigation
? null
: (
<>
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
{renderLogo()}
<MainNavSearchButton />
</div>
<div className="p-2">
<WorkspaceCard />
</div>
</>
)}
{showDetailNavigation
? showAppDetailNavigation
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
@ -304,31 +292,20 @@ const MainNav = ({
? <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
: showAgentDetailNavigation
? <AgentDetailSection expand={detailNavigationVisibleExpanded} />
: showDeploymentDetailNavigation
? <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: detailNavigationVisibleExpanded
? snippetNavigation.snippet && snippetNavigation.onFieldsChange
? (
<SnippetSidebarContent
snippet={snippetNavigation.snippet}
fields={snippetInputFields}
readonly={snippetNavigation.readonly}
onFieldsChange={snippetNavigation.onFieldsChange}
/>
)
: null
: <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} />
: (
<>
<nav className="flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && detailNavigationVisibleExpanded && (
: <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: showSnippetDetailBottomNavigation
? null
: (
<>
<nav className="flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>

View File

@ -262,7 +262,6 @@ describe('SnippetList', () => {
expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('heading', { name: 'workflow.tabs.snippets' })).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
@ -288,42 +287,6 @@ describe('SnippetList', () => {
})
})
it('does not pass published state to the snippets list query by default', () => {
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.not.objectContaining({
is_published: expect.any(Boolean),
}), {
enabled: true,
})
})
it('passes published state when selecting the published filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i }))
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({
is_published: true,
}), {
enabled: true,
})
})
it('passes draft state when selecting the draft filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /snippet\.draft/i }))
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({
is_published: false,
}), {
enabled: true,
})
})
it('updates the search query state from the search input', () => {
renderList()

View File

@ -1,26 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetPublishStatusFilter from '../snippet-publish-status-filter'
describe('SnippetPublishStatusFilter', () => {
it('should render the default published and draft filter label', () => {
render(<SnippetPublishStatusFilter value="all" onChange={vi.fn()} />)
expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument()
})
it('should emit the selected publish status from the dropdown', () => {
const onChange = vi.fn()
render(<SnippetPublishStatusFilter value="all" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i }))
expect(onChange).toHaveBeenCalledWith('published')
})
it('should render the selected draft status label', () => {
render(<SnippetPublishStatusFilter value="draft" onChange={vi.fn()} />)
expect(screen.getByRole('button', { name: /snippet\.draft/i })).toBeInTheDocument()
})
})

View File

@ -1,78 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type SnippetPublishStatus = 'all' | 'published' | 'draft'
type SnippetPublishStatusFilterProps = {
value: SnippetPublishStatus
onChange: (value: SnippetPublishStatus) => void
}
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const snippetPublishStatusValues: SnippetPublishStatus[] = ['all', 'published', 'draft']
const isSnippetPublishStatus = (value: string): value is SnippetPublishStatus => {
return snippetPublishStatusValues.includes(value as SnippetPublishStatus)
}
const SnippetPublishStatusFilter = ({
value,
onChange,
}: SnippetPublishStatusFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }) },
{ value: 'published', text: t('common.published', { ns: 'workflow' }) },
{ value: 'draft', text: t('draft', { ns: 'snippet' }) },
] satisfies Array<{ value: SnippetPublishStatus, text: string }>), [t])
const activeOption = options.find(option => option.value === value)
const isSelected = value !== 'all'
const defaultLabel = `${t('common.published', { ns: 'workflow' })} / ${t('draft', { ns: 'snippet' })}`
const triggerLabel = isSelected ? activeOption?.text : defaultLabel
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
chipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isSnippetPublishStatus(nextValue) && onChange(nextValue)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick>
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default SnippetPublishStatusFilter

View File

@ -1,6 +1,5 @@
'use client'
import type { SnippetPublishStatus } from './components/snippet-publish-status-filter'
import type { SnippetListItem } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
@ -19,7 +18,6 @@ import { StudioListHeader } from '../apps/studio-list-header'
import { canAccessSnippets } from '../snippets/utils/permission'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import SnippetPublishStatusFilter from './components/snippet-publish-status-filter'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
@ -29,14 +27,6 @@ const TagManagementModal = dynamic(() => import('@/features/tag-management/compo
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
const toSnippetPublishedQuery = (publishStatus: SnippetPublishStatus) => {
if (publishStatus === 'published')
return true
if (publishStatus === 'draft')
return false
return undefined
}
type SnippetCardSkeletonProps = {
count: number
}
@ -69,22 +59,16 @@ const SnippetList = () => {
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [publishStatus, setPublishStatus] = useState<SnippetPublishStatus>('all')
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
const snippetListQuery = useMemo(() => {
const isPublished = toSnippetPublishedQuery(publishStatus)
return {
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
...(typeof isPublished === 'boolean' ? { is_published: isPublished } : {}),
}
}, [creatorIDs, debouncedKeywords, publishStatus, tagIDs])
const snippetListQuery = useMemo(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
}), [creatorIDs, debouncedKeywords, tagIDs])
const canQuerySnippetList = canAccessSnippets(workspacePermissionKeys)
const {
@ -160,10 +144,6 @@ const SnippetList = () => {
value={creatorIDs}
onChange={setCreatorIDs}
/>
<SnippetPublishStatusFilter
value={publishStatus}
onChange={setPublishStatus}
/>
<TagFilter type="snippet" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expectLoadingButton } from '@/test/button'
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
describe('SaveBeforeLeavingDialog', () => {
it('should render the trigger and call discard or save actions', async () => {
const user = userEvent.setup()
const onDiscard = vi.fn()
const onSave = vi.fn()
render(
<SaveBeforeLeavingDialog
open
trigger={<button type="button">leave snippet</button>}
onDiscard={onDiscard}
onSave={onSave}
/>,
)
expect(screen.getByText('snippet.saveBeforeLeavingTitle')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.doNotSave' }))
await user.click(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
expect(onDiscard).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledTimes(1)
})
it('should disable destructive and save actions according to dialog state', () => {
render(
<SaveBeforeLeavingDialog
open
disabled
saveDisabled
loading
onDiscard={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'snippet.continueEditing' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
})
})

View File

@ -41,14 +41,22 @@ describe('SnippetChildren', () => {
it('should render snippet header and workflow panel with forwarded props', () => {
const callbacks = {
onCancel: vi.fn(),
onEdit: vi.fn(),
onExitEditing: vi.fn(),
onExitEditingWithoutSave: vi.fn(),
onPublish: vi.fn(),
onSaveAndExitEditing: vi.fn(),
}
render(
<SnippetChildren
snippetId="snippet-1"
fields={fields}
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
{...callbacks}
/>,
@ -58,7 +66,10 @@ describe('SnippetChildren', () => {
expect(screen.getByTestId('snippet-workflow-panel')).toBeInTheDocument()
expect(capturedHeaderProps).toEqual(expect.objectContaining({
snippetId: 'snippet-1',
canDiscardChanges: true,
canSave: true,
hasDraftChanges: true,
isEditing: true,
isPublishing: false,
...callbacks,
}))

View File

@ -1,20 +1,20 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetail, SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDraftStore } from '../../draft-store'
import SnippetMain from '../snippet-main'
const mockSyncInputFieldsDraft = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockSyncWorkflowDraftWhenPageClose = vi.fn()
const mockReset = vi.fn()
const mockSetNavigationState = vi.fn()
const mockSetFields = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
@ -24,7 +24,11 @@ const mockHandleStartWorkflowRun = vi.fn()
const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
const mockPush = vi.hoisted(() => vi.fn())
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['snippets.create_and_modify'],
}))
const mockInspectVarsCrud = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
@ -49,26 +53,35 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}),
}))
let capturedHooksStore: Record<string, unknown> | undefined
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
let snippetDetailStoreState: {
onFieldsChange?: (fields: SnippetInputField[]) => void
readonly: boolean
fields: SnippetInputField[]
reset: typeof mockReset
setNavigationState: typeof mockSetNavigationState
snippet?: SnippetDetail
snippetId?: string
setFields: typeof mockSetFields
}
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
}))
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
@ -151,15 +164,56 @@ vi.mock('@/app/components/workflow', () => ({
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
default: ({
onCancel,
onEdit,
onExitEditingWithoutSave,
onPublish,
canSave,
canEdit,
isEditing,
}: {
canSave: boolean
canEdit: boolean
isEditing: boolean
onCancel: () => void
onEdit: () => void
onExitEditingWithoutSave: () => void
onPublish: () => void
}) => (
<div>
{!isEditing && canEdit && <button type="button" onClick={onEdit}>edit</button>}
<a href="/snippets">snippets list</a>
<button type="button" onClick={onExitEditingWithoutSave}>exit without save</button>
<button type="button" disabled={!canSave} onClick={onPublish}>publish</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
default: ({
fields,
onFieldsChange,
}: {
fields: SnippetInputField[]
onFieldsChange: (fields: SnippetInputField[]) => void
}) => (
<div>
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
<button
type="button"
onClick={() => onFieldsChange([
...fields,
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
])}
>
submit
</button>
</div>
),
}))
@ -248,6 +302,13 @@ describe('SnippetMain', () => {
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: payload.graph,
input_fields: payload.inputFields,
},
refetch: vi.fn(),
})
const llmNodeMetadata = createNodeMetadata(BlockEnum.LLM)
const humanInputNodeMetadata = createNodeMetadata(BlockEnum.HumanInput)
const endNodeMetadata = createNodeMetadata(BlockEnum.End)
@ -267,24 +328,18 @@ describe('SnippetMain', () => {
},
})
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockSetNavigationState.mockImplementation((state) => {
snippetDetailStoreState = {
...snippetDetailStoreState,
...state,
}
})
capturedHooksStore = undefined
capturedWorkflowNodes = undefined
useSnippetDraftStore.getState().reset()
snippetDetailStoreState = {
readonly: true,
fields: [...payload.inputFields],
reset: mockReset,
setNavigationState: mockSetNavigationState,
setFields: mockSetFields,
}
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
})
describe('Initial Mode', () => {
it('should render the draft graph by default when there is no published workflow', () => {
it('should enter draft editing mode by default when there is no published workflow', () => {
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
@ -296,7 +351,8 @@ describe('SnippetMain', () => {
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
})
it('should keep the snippet canvas editable and sync draft changes without permission gating', async () => {
it('should stay readonly without snippet create-and-modify permission', async () => {
mockWorkspacePermissionKeys.value = []
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
@ -305,17 +361,14 @@ describe('SnippetMain', () => {
})
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({
readonly: false,
}))
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should render the draft graph even when a published workflow exists', async () => {
it('should enter readonly mode with published graph by default when published workflow exists', async () => {
const publishedNode = createDraftNode('published-node')
const draftNode = createDraftNode('draft-node')
@ -325,13 +378,35 @@ describe('SnippetMain', () => {
workflowDraftNodes: [draftNode],
})
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['published-node'])
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should switch from readonly published graph to draft graph without forced draft sync', async () => {
const publishedNode = createDraftNode('published-node')
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
hasPublishedWorkflow: true,
workflowNodes: [publishedNode],
workflowDraftNodes: [draftNode],
})
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
})
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
await doSyncWorkflowDraft(true)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
})
})
@ -339,12 +414,7 @@ describe('SnippetMain', () => {
it('should sync draft input_fields when removing a field from the panel', async () => {
renderSnippetMain({ currentNodes: [createDraftNode()] })
await waitFor(() => {
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
})
act(() => {
snippetDetailStoreState.onFieldsChange?.([])
})
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
@ -356,20 +426,7 @@ describe('SnippetMain', () => {
it('should sync draft input_fields when adding a field from the sidebar', async () => {
renderSnippetMain({ currentNodes: [createDraftNode()] })
await waitFor(() => {
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
})
act(() => {
snippetDetailStoreState.onFieldsChange?.([
...payload.inputFields,
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
])
})
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
@ -398,13 +455,94 @@ describe('SnippetMain', () => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith()
})
it('should sync workflow draft when the page closes', () => {
it('should sync workflow draft before routing without saving changes', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('link', { name: 'snippets list' }))
fireEvent.click(await screen.findByRole('button', { name: 'snippet.doNotSave' }))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets')
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockDoSyncWorkflowDraft.mock.invocationCallOrder[0]!).toBeLessThan(mockPush.mock.invocationCallOrder[0]!)
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
})
it('should sync workflow draft before exiting editing without saving changes', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
})
it('should not sync draft from workflow autosave while readonly', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
mockDoSyncWorkflowDraft.mockClear()
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void)
await doSyncWorkflowDraft()
syncWorkflowDraftWhenPageClose()
expect(mockSyncWorkflowDraftWhenPageClose).toHaveBeenCalledTimes(1)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockSyncWorkflowDraftWhenPageClose).not.toHaveBeenCalled()
})
it('should skip forced draft sync caused by re-entering editing mode', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
mockDoSyncWorkflowDraft.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
await doSyncWorkflowDraft(true)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should use latest synced draft when re-entering editing mode', async () => {
const latestDraftNode = {
id: 'latest-node',
position: { x: 10, y: 20 },
data: { type: BlockEnum.Code, title: 'Latest draft node' },
} as WorkflowProps['nodes'][number]
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
graph: {
nodes: [latestDraftNode],
edges: [],
viewport: { x: 30, y: 40, zoom: 1.2 },
},
input_fields: [payload.inputFields[0]],
})
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-node')
})
})
})
@ -493,6 +631,74 @@ describe('SnippetMain', () => {
})
})
describe('Cancel', () => {
it('should restore from the published workflow and reset published input fields', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalledWith({
graph: payload.graph,
input_fields: payload.inputFields,
})
expect(mockSetFields).toHaveBeenCalledWith(payload.inputFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(payload.inputFields, {
onRefresh: expect.any(Function),
})
})
})
it('should update local draft state with the published workflow after canceling changes', async () => {
const latestDraftNode = {
id: 'latest-draft-node',
position: { x: 10, y: 20 },
data: { type: BlockEnum.Code, title: 'Latest draft node' },
} as WorkflowProps['nodes'][number]
const publishedNode = {
id: 'published-node',
position: { x: 30, y: 40 },
data: { type: BlockEnum.Code, title: 'Published node' },
} as WorkflowProps['nodes'][number]
const publishedWorkflow = {
graph: {
nodes: [publishedNode],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
input_fields: payload.inputFields,
}
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: publishedWorkflow,
refetch: vi.fn(),
})
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
graph: {
nodes: [latestDraftNode],
edges: [],
viewport: { x: 30, y: 40, zoom: 1.2 },
},
input_fields: payload.inputFields,
})
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-draft-node')
})
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('published-node')
})
})
})
describe('Inspect Vars', () => {
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()

View File

@ -53,6 +53,20 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({
},
}))
vi.mock('@/next/link', () => ({
default: ({
children,
href,
className,
}: {
children: React.ReactNode
href: string
className?: string
}) => (
<a href={href} className={className}>{children}</a>
),
}))
vi.mock('@/app/components/workflow/nodes/start/components/var-list', () => ({
default: (props: {
list: InputVar[]
@ -141,11 +155,7 @@ describe('SnippetSidebar', () => {
/>,
)
expect(screen.queryByRole('link', { name: /snippet\.management/i })).not.toBeInTheDocument()
expect(screen.getByText(snippet.name)).toHaveAttribute('title', snippet.name)
expect(screen.getByText(snippet.name)).toHaveClass('truncate')
expect(screen.getByText(snippet.description)).toHaveAttribute('title', snippet.description)
expect(screen.getByText(snippet.description)).toHaveClass('truncate')
expect(screen.getByRole('link', { name: /snippet\.management/i })).toHaveAttribute('href', '/snippets')
expect(screen.queryByRole('button', { name: /common\.operation\.add/i })).not.toBeInTheDocument()
expect(capturedVarListProps?.readonly).toBe(true)
})

View File

@ -1,10 +1,15 @@
import type { SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDraftStore } from '../../../draft-store'
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
const mockSyncInputFieldsDraft = vi.fn()
const mockSetFields = vi.fn()
let snippetDetailStoreState: {
fields: SnippetInputField[]
setFields: typeof mockSetFields
}
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
@ -12,6 +17,10 @@ vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
}),
}))
vi.mock('../../../store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
type: PipelineInputVarType.textInput,
label: 'Blog URL',
@ -23,13 +32,19 @@ const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputFi
describe('useSnippetInputFieldActions', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDraftStore.getState().reset()
snippetDetailStoreState = {
fields: [],
setFields: mockSetFields,
}
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
snippetDetailStoreState.fields = fields
})
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
})
describe('Field sync', () => {
it('should update fields and sync the draft', () => {
useSnippetDraftStore.getState().setInputFields([createField()])
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
@ -45,8 +60,8 @@ describe('useSnippetInputFieldActions', () => {
result.current.handleFieldsChange(nextFields)
})
expect(result.current.fields).toEqual(nextFields)
expect(useSnippetDraftStore.getState().inputFields).toEqual(nextFields)
expect(result.current.fields).toEqual([createField()])
expect(mockSetFields).toHaveBeenCalledWith(nextFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(nextFields, {
onRefresh: expect.any(Function),
})

View File

@ -77,7 +77,7 @@ describe('useSnippetPublish', () => {
expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalledTimes(1)
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
expect(toast.success).toHaveBeenCalledWith('snippet.saveSuccess')
})
it('should not publish the snippet when checklist validation fails', async () => {

View File

@ -1,8 +1,8 @@
import type { SnippetInputField } from '@/models/snippet'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useSnippetDraftStore } from '../../draft-store'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
import { useSnippetDetailStore } from '../../store'
type UseSnippetInputFieldActionsOptions = {
canEdit?: boolean
@ -15,25 +15,25 @@ export const useSnippetInputFieldActions = ({
}: UseSnippetInputFieldActionsOptions) => {
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
const {
inputFields,
setInputFields,
} = useSnippetDraftStore(useShallow(state => ({
inputFields: state.inputFields,
setInputFields: state.setInputFields,
fields,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
fields: state.fields,
setFields: state.setFields,
})))
const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => {
if (!canEdit)
return
setInputFields(newFields)
setFields(newFields)
void syncInputFieldsDraft(newFields, {
onRefresh: setInputFields,
onRefresh: setFields,
})
}, [canEdit, setInputFields, syncInputFieldsDraft])
}, [canEdit, setFields, syncInputFieldsDraft])
return {
fields: inputFields,
fields,
handleFieldsChange,
}
}

View File

@ -42,7 +42,7 @@ export const useSnippetPublish = ({
)
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
resetWorkflowVersionHistory()
toast.success(t('publishSuccess'))
toast.success(t('saveSuccess'))
return true
}
catch (error) {

View File

@ -0,0 +1,78 @@
'use client'
import type { ReactElement } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '@langgenius/dify-ui/alert-dialog'
import { useTranslation } from 'react-i18next'
type SaveBeforeLeavingDialogProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
trigger?: ReactElement
disabled?: boolean
saveDisabled?: boolean
loading?: boolean
onDiscard: () => void | Promise<void>
onSave: () => void | Promise<void>
}
const SaveBeforeLeavingDialog = ({
open,
onOpenChange,
trigger,
disabled,
saveDisabled,
loading,
onDiscard,
onSave,
}: SaveBeforeLeavingDialogProps) => {
const { t } = useTranslation('snippet')
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
{trigger && (
<AlertDialogTrigger render={trigger} />
)}
<AlertDialogContent className="w-165">
<div className="space-y-2 p-8 pb-12">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('saveBeforeLeavingTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t('saveBeforeLeavingDescription')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="px-8 pt-0">
<AlertDialogCancelButton disabled={disabled || loading}>
{t('continueEditing')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
disabled={disabled || loading}
onClick={onDiscard}
>
{t('doNotSave')}
</AlertDialogConfirmButton>
<AlertDialogConfirmButton
tone="default"
loading={loading}
disabled={disabled || saveDisabled || loading}
onClick={onSave}
>
{t('saveAndExit')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default SaveBeforeLeavingDialog

View File

@ -7,17 +7,35 @@ import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
snippetId: string
fields: SnippetInputField[]
canDiscardChanges: boolean
canEdit?: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const SnippetChildren = ({
snippetId,
fields,
canDiscardChanges,
canEdit = true,
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: SnippetChildrenProps) => {
return (
<>
@ -25,9 +43,18 @@ const SnippetChildren = ({
<SnippetHeader
snippetId={snippetId}
canDiscardChanges={canDiscardChanges}
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
<SnippetWorkflowPanel

View File

@ -1,28 +0,0 @@
'use client'
import { SnippetPlaceholderIcon } from './snippet-placeholder-icon'
export function SnippetCollapsedPreview({
inputFieldCount,
}: {
inputFieldCount: number
}) {
return (
<div
className="flex min-h-0 grow flex-col items-center px-2 pt-4"
aria-label="Snippet collapsed preview"
>
<SnippetPlaceholderIcon />
<div className="my-4 h-px w-8 rounded-full bg-divider-subtle" aria-hidden="true" />
<div
className="relative flex size-8 items-center justify-center rounded-lg border border-divider-subtle bg-background-default-subtle text-text-accent shadow-xs"
aria-label={`${inputFieldCount} input fields`}
>
<span aria-hidden="true" className="i-custom-vender-solid-development-variable-02 size-5" />
<span className="absolute -right-1.5 -bottom-1.5 flex size-4 items-center justify-center rounded-full border-2 border-components-panel-bg bg-state-accent-solid text-2xs leading-none text-text-primary-on-surface shadow-xs">
{inputFieldCount}
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CancelChanges from '../cancel-changes'
describe('CancelChanges', () => {
it('should render editing state without discard action when changes cannot be discarded', () => {
render(<CancelChanges canDiscardChanges={false} onCancel={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'snippet.discardDraft' })).not.toBeInTheDocument()
expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument()
})
it('should confirm before discarding draft changes', async () => {
const user = userEvent.setup()
const onCancel = vi.fn().mockResolvedValue(undefined)
render(<CancelChanges canDiscardChanges onCancel={onCancel} />)
await user.click(screen.getByRole('button', { name: 'snippet.discardDraft' }))
expect(screen.getByText('snippet.discardChangesTitle')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.discardChanges' }))
await waitFor(() => {
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,8 +1,28 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { expectLoadingButton } from '@/test/button'
import SnippetHeader from '..'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({
children,
disabled,
onClick,
}: {
children: ReactNode
disabled?: boolean
onClick?: () => void
}) => <button type="button" disabled={disabled} onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? <button type="button">{children}</button>,
}))
vi.mock('@/app/components/workflow/header', () => ({
default: (props: HeaderProps) => {
return (
@ -24,71 +44,242 @@ vi.mock('@/app/components/workflow/header', () => ({
}))
describe('SnippetHeader', () => {
const mockCancel = vi.fn()
const mockEdit = vi.fn()
const mockExitEditing = vi.fn()
const mockExitEditingWithoutSave = vi.fn()
const mockPublish = vi.fn()
const mockSaveAndExit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
isPublishing={false}
onPublish={mockPublish}
/>,
)
// Verifies the wrapper passes the expected workflow header configuration.
describe('Rendering', () => {
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges={false}
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
expect(screen.queryByText('snippet.viewOnly')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /snippet\.edit/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /snippet\.exitEditing/i })).not.toBeInTheDocument()
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
})
})
it('should publish from the primary header action', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
isPublishing={false}
onPublish={mockPublish}
/>,
)
// Verifies forwarded callbacks still drive the snippet-specific controls.
describe('User Interactions', () => {
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
expect(mockPublish).toHaveBeenCalledTimes(1)
})
expect(mockPublish).toHaveBeenCalledTimes(1)
expect(mockCancel).toHaveBeenCalledTimes(1)
})
it('should disable publish when the current graph has no nodes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave={false}
isPublishing={false}
onPublish={mockPublish}
/>,
)
it('should disable save actions when the current graph has no nodes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave={false}
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeDisabled()
})
expect(screen.getByRole('button', { name: /^snippet\.save$/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /snippet\.saveAndExit/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /snippet\.doNotSave/i })).not.toBeDisabled()
})
it('should show publish loading state while publishing', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
isPublishing
onPublish={mockPublish}
/>,
)
it('should hide the discard draft action when there is no published workflow', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges={false}
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
expectLoadingButton(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(screen.queryByText('snippet.discardDraft')).not.toBeInTheDocument()
expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument()
})
it('should enter editing mode from the readonly header action', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges={false}
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.edit' }))
expect(mockEdit).toHaveBeenCalledTimes(1)
})
it('should exit editing immediately when there are no draft changes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges={false}
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
expect(mockExitEditing).toHaveBeenCalledTimes(1)
expect(mockExitEditingWithoutSave).not.toHaveBeenCalled()
expect(mockSaveAndExit).not.toHaveBeenCalled()
})
it('should disable edit actions while publishing', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
expect(screen.getByRole('button', { name: 'snippet.exitEditing' })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: /^snippet\.save$/i }))
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
})
it('should discard changes from the exit confirmation dialog', async () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.doNotSave' }))
await waitFor(() => {
expect(mockExitEditingWithoutSave).toHaveBeenCalledTimes(1)
})
expect(mockSaveAndExit).not.toHaveBeenCalled()
})
it('should save and exit from the exit confirmation dialog', async () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
await waitFor(() => {
expect(mockSaveAndExit).toHaveBeenCalledTimes(1)
})
expect(mockExitEditingWithoutSave).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,81 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '@langgenius/dify-ui/alert-dialog'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
type CancelChangesProps = {
canDiscardChanges: boolean
onCancel: () => void | Promise<void>
}
const CancelChanges = ({
canDiscardChanges,
onCancel,
}: CancelChangesProps) => {
const { t } = useTranslation('snippet')
const [open, setOpen] = useState(false)
const [isDiscarding, setIsDiscarding] = useState(false)
const handleDiscardChanges = useCallback(async () => {
setIsDiscarding(true)
try {
await onCancel()
setOpen(false)
}
finally {
setIsDiscarding(false)
}
}, [onCancel])
return (
<div className="flex items-center gap-2 system-sm-regular">
{canDiscardChanges && (
<>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
className="system-sm-semibold text-text-accent hover:text-text-accent-secondary"
>
{t('discardDraft')}
</AlertDialogTrigger>
<AlertDialogContent className="w-160">
<div className="space-y-2 p-8 pb-12">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('discardChangesTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t('discardChangesDescription')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="px-8 pt-0">
<AlertDialogCancelButton disabled={isDiscarding}>
{t('continueEditing')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDiscarding}
disabled={isDiscarding}
onClick={handleDiscardChanges}
>
{t('discardChanges')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<span className="text-text-quaternary">·</span>
</>
)}
<span className="text-text-tertiary">{t('editingDraft')}</span>
</div>
)
}
export default memo(CancelChanges)

View File

@ -5,42 +5,128 @@ import { Button } from '@langgenius/dify-ui/button'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Header from '@/app/components/workflow/header'
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
import CancelChanges from './cancel-changes'
import RunMode from './run-mode'
type SnippetHeaderProps = {
snippetId: string
canDiscardChanges: boolean
canEdit?: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const PublishAction = ({
canSave,
isPublishing,
onPublish,
}: Pick<SnippetHeaderProps, 'canSave' | 'isPublishing' | 'onPublish'>) => {
const ViewOnlyBadge = () => {
const { t } = useTranslation('snippet')
return (
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing || !canSave}
onClick={onPublish}
>
{t('publishButton')}
</Button>
<div className="rounded-md border border-components-badge-status-light-normal-border-inner bg-components-badge-bg-blue-light-soft px-1.5 py-0.5 system-xs-semibold-uppercase text-text-accent">
{t('viewOnly')}
</div>
)
}
const EditActions = ({
canEdit = true,
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: Pick<SnippetHeaderProps, 'canEdit' | 'canSave' | 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onEdit' | 'onExitEditing' | 'onExitEditingWithoutSave' | 'onPublish' | 'onSaveAndExitEditing'>) => {
const { t } = useTranslation('snippet')
const [exitConfirmOpen, setExitConfirmOpen] = useState(false)
if (!isEditing) {
if (!canEdit)
return null
return (
<Button variant="primary" onClick={onEdit}>
{t('edit')}
</Button>
)
}
return (
<>
<SaveBeforeLeavingDialog
open={exitConfirmOpen}
onOpenChange={setExitConfirmOpen}
trigger={(
<Button
disabled={isPublishing || !canEdit}
onClick={(event) => {
if (!canEdit)
return
if (!hasDraftChanges) {
event.preventDefault()
void onExitEditing()
return
}
setExitConfirmOpen(true)
}}
>
{t('exitEditing')}
</Button>
)}
disabled={isPublishing || !canEdit}
saveDisabled={!canEdit || !canSave}
loading={isPublishing}
onDiscard={async () => {
await onExitEditingWithoutSave()
setExitConfirmOpen(false)
}}
onSave={async () => {
await onSaveAndExitEditing()
setExitConfirmOpen(false)
}}
/>
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing || !canEdit || !canSave}
onClick={onPublish}
>
{t('save')}
</Button>
</>
)
}
const SnippetHeader = ({
snippetId,
canDiscardChanges,
canEdit = true,
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
const viewHistoryProps = useMemo(() => {
@ -53,11 +139,21 @@ const SnippetHeader = ({
return {
normal: {
components: {
title: isEditing
? (hasDraftChanges ? <CancelChanges canDiscardChanges={canDiscardChanges} onCancel={onCancel} /> : <></>)
: <ViewOnlyBadge />,
left: (
<PublishAction
<EditActions
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
),
},
@ -78,7 +174,7 @@ const SnippetHeader = ({
viewHistoryProps,
},
}
}, [canSave, isPublishing, onPublish, t, viewHistoryProps])
}, [canDiscardChanges, canEdit, canSave, hasDraftChanges, isEditing, isPublishing, onCancel, onEdit, onExitEditing, onExitEditingWithoutSave, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
return <Header {...headerProps} />
}

View File

@ -8,8 +8,8 @@ import { toast } from '@langgenius/dify-ui/toast'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
@ -23,7 +23,9 @@ import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
import { useSnippetDraftStore } from '../draft-store'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useConfigsMap } from '../hooks/use-configs-map'
import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url'
import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud'
@ -32,9 +34,12 @@ import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
import { useSnippetRun } from '../hooks/use-snippet-run'
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
import { useSnippetDetailStore } from '../store'
import { canCreateAndModifySnippets } from '../utils/permission'
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
import { useSnippetPublish } from './hooks/use-snippet-publish'
import SaveBeforeLeavingDialog from './save-before-leaving-dialog'
import SnippetChildren from './snippet-children'
import SnippetSidebar from './snippet-sidebar'
type SnippetMainProps = {
payload: SnippetDetailPayload
@ -50,9 +55,19 @@ type SnippetMainProps = {
type SnippetMainContentProps = {
snippetId: string
fields: SnippetInputField[]
canDiscardChanges: boolean
canEdit: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
onBeforePublish: () => Promise<Omit<SnippetDraftSyncPayload, 'hash'> | void>
onCancel: () => void | Promise<void>
onDiscardRoute: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onSaved: (syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void) => void
onSavedAndExitEditing: () => void
}
const unsupportedSnippetBlockTypes = new Set([
@ -79,11 +94,23 @@ const hasSnippetDraftNodes = (payload?: Omit<SnippetDraftSyncPayload, 'hash'> |
const SnippetMainContent = ({
snippetId,
fields,
canDiscardChanges,
canEdit,
canSave,
hasDraftChanges,
isEditing,
onBeforePublish,
onCancel,
onDiscardRoute,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onSaved,
onSavedAndExitEditing,
}: SnippetMainContentProps) => {
const { push } = useRouter()
const { t } = useTranslation('snippet')
const [pendingHref, setPendingHref] = useState<string>()
const {
handlePublish,
isPublishing,
@ -108,42 +135,181 @@ const SnippetMainContent = ({
return didSave
}, [handlePublish, onBeforePublish, onSaved, t])
const handleSaveAndExitEditing = useCallback(async () => {
const didSave = await handlePublishSnippet()
if (didSave)
onSavedAndExitEditing()
}, [handlePublishSnippet, onSavedAndExitEditing])
const navigateToPendingHref = useCallback((href: string) => {
const url = new URL(href, window.location.href)
if (url.origin === window.location.origin)
push(`${url.pathname}${url.search}${url.hash}`)
else
window.location.assign(url.href)
}, [push])
const handleDiscardAndRoute = useCallback(async () => {
if (!pendingHref)
return
await onDiscardRoute()
navigateToPendingHref(pendingHref)
setPendingHref(undefined)
}, [navigateToPendingHref, onDiscardRoute, pendingHref])
const handleSaveAndRoute = useCallback(async () => {
if (!pendingHref)
return
const didSave = await handlePublishSnippet()
if (!didSave)
return
navigateToPendingHref(pendingHref)
setPendingHref(undefined)
}, [handlePublishSnippet, navigateToPendingHref, pendingHref])
useEffect(() => {
if (!isEditing || !hasDraftChanges)
return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasDraftChanges, isEditing])
useEffect(() => {
if (!isEditing || !hasDraftChanges)
return
const handleClick = (event: MouseEvent) => {
if (
event.defaultPrevented
|| event.button !== 0
|| event.metaKey
|| event.ctrlKey
|| event.shiftKey
|| event.altKey
) {
return
}
const anchor = (event.target as Element | null)?.closest?.('a[href]')
if (!(anchor instanceof HTMLAnchorElement))
return
if (anchor.target && anchor.target !== '_self')
return
if (anchor.hasAttribute('download'))
return
const nextUrl = new URL(anchor.href, window.location.href)
const currentUrl = new URL(window.location.href)
if (nextUrl.href === currentUrl.href)
return
event.preventDefault()
event.stopPropagation()
setPendingHref(nextUrl.href)
}
document.addEventListener('click', handleClick, true)
return () => document.removeEventListener('click', handleClick, true)
}, [hasDraftChanges, isEditing])
return (
<SnippetChildren
snippetId={snippetId}
fields={fields}
canSave={canSave}
isPublishing={isPublishing}
onPublish={handlePublishSnippet}
/>
<>
<SnippetChildren
snippetId={snippetId}
fields={fields}
canDiscardChanges={canDiscardChanges}
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={handlePublishSnippet}
onSaveAndExitEditing={handleSaveAndExitEditing}
/>
<SaveBeforeLeavingDialog
open={!!pendingHref}
onOpenChange={open => !open && setPendingHref(undefined)}
disabled={isPublishing}
saveDisabled={!canSave}
loading={isPublishing}
onDiscard={handleDiscardAndRoute}
onSave={handleSaveAndRoute}
/>
</>
)
}
const SnippetMain = ({
payload,
draftPayload,
hasInitialDraftChanges,
hasPublishedWorkflow,
snippetId,
nodes,
edges,
viewport,
draftNodes,
draftEdges,
draftViewport,
}: SnippetMainProps) => {
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys)
const [isEditingState, setIsEditingState] = useState(!hasPublishedWorkflow)
const isEditing = canCreateAndModifySnippet && isEditingState
const [localDraftState, setLocalDraftState] = useState<LocalDraftState>()
const [localDraftSnippetId, setLocalDraftSnippetId] = useState(snippetId)
if (localDraftSnippetId !== snippetId) {
const [draftChangeState, setDraftChangeState] = useState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
if (draftChangeState.snippetId !== snippetId || draftChangeState.initial !== hasInitialDraftChanges) {
setLocalDraftState(undefined)
setLocalDraftSnippetId(snippetId)
setDraftChangeState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
}
const hasDraftChanges = draftChangeState.value
const currentCanvasNodeCount = useStore(state => state.nodes.filter(node => !node.data?._isTempNode).length)
const skipNextForcedDraftSyncRef = useRef(false)
const setHasDraftChanges = useCallback((value: boolean) => {
setDraftChangeState(prev => ({
...prev,
value,
}))
}, [])
const effectiveDraftPayload = localDraftState?.payload ?? draftPayload
const effectiveDraftNodes = localDraftState?.nodes ?? draftNodes
const effectiveDraftEdges = localDraftState?.edges ?? draftEdges
const effectiveDraftViewport = localDraftState?.viewport ?? draftViewport
const { graph, snippet } = effectiveDraftPayload
const displayPayload = isEditing ? effectiveDraftPayload : payload
const displayNodes = isEditing ? effectiveDraftNodes : nodes
const displayEdges = isEditing ? effectiveDraftEdges : edges
const displayViewport = isEditing ? effectiveDraftViewport : viewport
const { graph, snippet } = displayPayload
const canSave = currentCanvasNodeCount > 0
const {
doSyncWorkflowDraft: syncWorkflowDraft,
syncInputFieldsDraft,
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft(snippetId)
const workflowStore = useWorkflowStore()
const publishedWorkflowQuery = useSnippetPublishedWorkflow(snippetId)
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const {
handleBackupDraft,
@ -173,6 +339,10 @@ const SnippetMain = ({
invalidateConversationVarValues,
} = useInspectVarsCrud(snippetId)
const workflowAvailableNodesMetaData = useAvailableNodesMetaData()
const {
data: publishedWorkflow,
refetch: refetchPublishedWorkflow,
} = publishedWorkflowQuery
const availableNodesMetaData = useMemo(() => {
const nodes = workflowAvailableNodesMetaData.nodes.filter(node =>
!unsupportedSnippetBlockTypes.has(node.metaData.type))
@ -194,23 +364,16 @@ const SnippetMain = ({
}, [workflowAvailableNodesMetaData])
const {
reset,
setNavigationState,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
reset: state.reset,
setNavigationState: state.setNavigationState,
})))
const {
hydrateDraft,
setInputFields,
} = useSnippetDraftStore(useShallow(state => ({
hydrateDraft: state.hydrateDraft,
setInputFields: state.setInputFields,
setFields: state.setFields,
})))
const {
fields,
handleFieldsChange: handleSnippetFieldsChange,
handleFieldsChange,
} = useSnippetInputFieldActions({
canEdit: true,
canEdit: canCreateAndModifySnippet,
snippetId,
})
const {
@ -222,53 +385,67 @@ const SnippetMain = ({
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId)
useEffect(() => {
reset()
return () => reset()
}, [reset, snippetId])
useLayoutEffect(() => {
hydrateDraft({
snippetId,
inputFields: effectiveDraftPayload.inputFields,
})
}, [effectiveDraftPayload.inputFields, hydrateDraft, snippetId])
useEffect(() => {
setFields(displayPayload.inputFields)
}, [displayPayload.inputFields, setFields, snippetId])
useEffect(() => {
workflowStore.setState({ canvasReadOnly: false })
workflowStore.setState({ canvasReadOnly: !isEditing })
return () => {
workflowStore.setState({ canvasReadOnly: false })
}
}, [workflowStore])
}, [isEditing, workflowStore])
useEffect(() => {
workflowStore.temporal.getState().pause()
workflowStore.getState().setWorkflowHistory({
nodes: effectiveDraftNodes,
edges: effectiveDraftEdges,
nodes: displayNodes,
edges: displayEdges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
})
workflowStore.temporal.getState().clear()
workflowStore.temporal.getState().resume()
}, [effectiveDraftEdges, effectiveDraftNodes, workflowStore])
}, [displayEdges, displayNodes, workflowStore])
const doSyncWorkflowDraft = useCallback((
...args: Parameters<typeof syncWorkflowDraft>
) => syncWorkflowDraft(...args), [syncWorkflowDraft])
) => {
if (!canCreateAndModifySnippet || !isEditing)
return Promise.resolve()
const handleFieldsChange = useCallback((nextFields: SnippetInputField[]) => {
handleSnippetFieldsChange(nextFields)
}, [handleSnippetFieldsChange])
const [
notRefreshWhenSyncError,
callback,
] = args
if (skipNextForcedDraftSyncRef.current && notRefreshWhenSyncError === true && !callback) {
skipNextForcedDraftSyncRef.current = false
return Promise.resolve()
}
useEffect(() => {
setNavigationState({
snippetId,
snippet,
readonly: false,
onFieldsChange: handleFieldsChange,
})
}, [handleFieldsChange, setNavigationState, snippet, snippetId])
if (isEditing)
setHasDraftChanges(true)
return syncWorkflowDraft(...args)
}, [canCreateAndModifySnippet, isEditing, setHasDraftChanges, syncWorkflowDraft])
const syncWorkflowDraftWhenPageCloseInEditing = useCallback(() => {
if (!canCreateAndModifySnippet || !isEditing)
return
syncWorkflowDraftWhenPageClose()
}, [canCreateAndModifySnippet, isEditing, syncWorkflowDraftWhenPageClose])
const handleFieldsChangeInEditing = useCallback((nextFields: SnippetInputField[]) => {
if (!canCreateAndModifySnippet || !isEditing)
return
handleFieldsChange(nextFields)
setHasDraftChanges(true)
}, [canCreateAndModifySnippet, handleFieldsChange, isEditing, setHasDraftChanges])
const updateLocalDraftFromSyncPayload = useCallback((
syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void,
@ -299,13 +476,70 @@ const SnippetMain = ({
edges: initialEdges(draftGraph.edges, draftGraph.nodes),
viewport: draftGraph.viewport,
})
setInputFields(inputFields)
}, [draftPayload, fields, setInputFields])
setFields(inputFields)
}, [draftPayload, fields, setFields])
const handleCancelChanges = useCallback(async () => {
if (!canCreateAndModifySnippet)
return
const workflow = publishedWorkflow ?? (await refetchPublishedWorkflow()).data
if (!workflow)
return
handleRestoreFromPublishedWorkflow(workflow as never)
const publishedInputFields = Array.isArray(workflow.input_fields)
? workflow.input_fields as SnippetInputField[]
: []
updateLocalDraftFromSyncPayload({
graph: workflow.graph,
input_fields: publishedInputFields,
})
void syncInputFieldsDraft(publishedInputFields, {
onRefresh: setFields,
})
setHasDraftChanges(false)
}, [canCreateAndModifySnippet, handleRestoreFromPublishedWorkflow, publishedWorkflow, refetchPublishedWorkflow, setFields, setHasDraftChanges, syncInputFieldsDraft, updateLocalDraftFromSyncPayload])
const handleExitEditing = useCallback(async () => {
if (!canCreateAndModifySnippet || hasDraftChanges)
return
setIsEditingState(false)
}, [canCreateAndModifySnippet, hasDraftChanges])
const handleExitEditingWithoutSave = useCallback(async () => {
if (!canCreateAndModifySnippet)
return
const syncedDraftPayload = await syncWorkflowDraft(true)
updateLocalDraftFromSyncPayload(syncedDraftPayload)
skipNextForcedDraftSyncRef.current = true
setIsEditingState(false)
}, [canCreateAndModifySnippet, syncWorkflowDraft, updateLocalDraftFromSyncPayload])
const handleDiscardAndRoute = useCallback(async () => {
if (!canCreateAndModifySnippet)
return
const syncedDraftPayload = await syncWorkflowDraft(true)
updateLocalDraftFromSyncPayload(syncedDraftPayload)
skipNextForcedDraftSyncRef.current = true
}, [canCreateAndModifySnippet, syncWorkflowDraft, updateLocalDraftFromSyncPayload])
const handleEdit = useCallback(() => {
if (!canCreateAndModifySnippet)
return
skipNextForcedDraftSyncRef.current = true
setIsEditingState(true)
}, [canCreateAndModifySnippet])
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
syncWorkflowDraftWhenPageClose: syncWorkflowDraftWhenPageCloseInEditing,
handleRefreshWorkflowDraft,
handleBackupDraft,
handleLoadBackupDraft,
@ -361,25 +595,47 @@ const SnippetMain = ({
renameInspectVarName,
resetConversationVar,
resetToLastRunVar,
syncWorkflowDraftWhenPageClose,
syncWorkflowDraftWhenPageCloseInEditing,
])
return (
<div className="relative flex h-full min-h-0 min-w-0">
<SnippetSidebar
snippet={snippet}
fields={fields}
readonly={!isEditing}
onFieldsChange={handleFieldsChangeInEditing}
/>
<div className="relative min-h-0 min-w-0 grow">
<WorkflowWithInnerContext
key={`${snippetId}-draft`}
nodes={effectiveDraftNodes}
edges={effectiveDraftEdges}
viewport={effectiveDraftViewport ?? graph.viewport}
key={`${snippetId}-${isEditing ? 'draft' : 'published'}`}
nodes={displayNodes}
edges={displayEdges}
viewport={displayViewport ?? graph.viewport}
hooksStore={hooksStore as unknown as Partial<HooksStoreShape>}
>
<SnippetMainContent
snippetId={snippetId}
fields={fields}
canDiscardChanges={hasPublishedWorkflow}
canEdit={canCreateAndModifySnippet}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
onBeforePublish={() => syncWorkflowDraft(true)}
onSaved={updateLocalDraftFromSyncPayload}
onCancel={handleCancelChanges}
onDiscardRoute={handleDiscardAndRoute}
onEdit={handleEdit}
onExitEditing={handleExitEditing}
onExitEditingWithoutSave={handleExitEditingWithoutSave}
onSaved={(syncedDraftPayload) => {
updateLocalDraftFromSyncPayload(syncedDraftPayload)
setHasDraftChanges(false)
}}
onSavedAndExitEditing={() => {
setHasDraftChanges(false)
setIsEditingState(false)
}}
/>
</WorkflowWithInnerContext>
</div>

View File

@ -1,30 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
type SnippetPlaceholderIconProps = {
className?: string
graphicClassName?: string
}
export function SnippetPlaceholderIcon({
className,
graphicClassName,
}: SnippetPlaceholderIconProps) {
return (
<div
className={cn(
'flex size-10 items-center justify-center rounded-[10px] border border-divider-subtle bg-background-default-subtle text-text-tertiary shadow-xs',
className,
)}
aria-hidden="true"
>
<span className={cn('relative block size-8', graphicClassName)}>
<span className="absolute top-1/2 left-1/2 h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-util-colors-blue-blue-500" />
<span className="absolute top-0.5 left-0.5 size-2.5 rounded-xs bg-util-colors-blue-blue-300 shadow-xs" />
<span className="absolute top-1/2 right-0.5 size-2.5 -translate-y-1/2 rounded-xs bg-util-colors-blue-blue-600 shadow-xs" />
<span className="absolute bottom-0.5 left-0.5 size-2.5 rounded-xs bg-util-colors-indigo-indigo-400 shadow-xs" />
</span>
</div>
)
}

View File

@ -11,8 +11,8 @@ import SnippetInfoDropdown from '@/app/components/app-sidebar/snippet-info/dropd
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import VarList from '@/app/components/workflow/nodes/start/components/var-list'
import Link from '@/next/link'
import { hasDuplicateStr } from '@/utils/var'
import { SnippetPlaceholderIcon } from './snippet-placeholder-icon'
type SnippetSidebarProps = {
snippet: SnippetDetail
@ -21,10 +21,6 @@ type SnippetSidebarProps = {
onFieldsChange: (fields: SnippetInputField[]) => void
}
type SnippetSidebarContentProps = SnippetSidebarProps & {
className?: string
}
const toWorkflowInputVar = (field: SnippetInputField): InputVar => ({
...field,
type: field.type as unknown as InputVar['type'],
@ -36,13 +32,12 @@ const toSnippetInputField = (field: InputVar): SnippetInputField => ({
type: field.type as unknown as SnippetInputField['type'],
})
export const SnippetSidebarContent = ({
const SnippetSidebar = ({
snippet,
fields,
readonly,
onFieldsChange,
className,
}: SnippetSidebarContentProps) => {
}: SnippetSidebarProps) => {
const { t } = useTranslation()
const [isShowAddVarModal, setIsShowAddVarModal] = useState(false)
const workflowInputVars = useMemo(() => fields.map(toWorkflowInputVar), [fields])
@ -93,23 +88,32 @@ export const SnippetSidebarContent = ({
}, [fields, onFieldsChange])
return (
<div className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-background-default', className)}>
<div className="shrink-0 px-3 py-2">
<div className="flex items-center gap-3">
<SnippetPlaceholderIcon />
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
<div className="shrink-0 px-6 pt-7">
<Link
href="/snippets"
className="inline-flex items-center gap-2 system-sm-semibold-uppercase text-text-primary hover:text-text-accent"
>
<span aria-hidden className="i-ri-arrow-left-line h-4 w-4" />
{t('management', { ns: 'snippet' })}
</Link>
<div className="mt-12 flex items-start gap-3">
<div className="min-w-0 grow">
<div className="truncate system-xl-semibold text-text-primary" title={snippet.name}>{snippet.name}</div>
<div className="system-xl-semibold text-text-primary">{snippet.name}</div>
{!!snippet.description && (
<div className="mt-3 system-sm-regular text-text-tertiary">
{snippet.description}
</div>
)}
</div>
<SnippetInfoDropdown snippet={snippet} />
</div>
{!!snippet.description && (
<div className="mt-2 truncate system-sm-regular text-text-tertiary" title={snippet.description}>
{snippet.description}
</div>
)}
</div>
<div className="flex min-h-0 grow flex-col px-3 pt-6">
<div className="mx-6 mt-7 h-px shrink-0 bg-divider-subtle" />
<div className="flex min-h-0 grow flex-col px-6 pt-7">
<Field
title={t('inputVariables', { ns: 'snippet' })}
operations={!readonly
@ -147,14 +151,6 @@ export const SnippetSidebarContent = ({
varKeys={fields.map(v => v.variable)}
/>
)}
</div>
)
}
const SnippetSidebar = (props: SnippetSidebarProps) => {
return (
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
<SnippetSidebarContent {...props} />
</aside>
)
}

View File

@ -1,36 +0,0 @@
import type { SnippetInputField } from '@/models/snippet'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDraftStore } from '..'
const createField = (variable: string): SnippetInputField => ({
label: variable,
variable,
type: PipelineInputVarType.textInput,
required: true,
})
describe('useSnippetDraftStore', () => {
beforeEach(() => {
useSnippetDraftStore.getState().reset()
})
it('should store and reset snippet input fields', () => {
const inputFields = [
createField('topic'),
createField('audience'),
]
useSnippetDraftStore.getState().hydrateDraft({
snippetId: 'snippet-1',
inputFields,
})
expect(useSnippetDraftStore.getState().snippetId).toBe('snippet-1')
expect(useSnippetDraftStore.getState().inputFields).toEqual(inputFields)
useSnippetDraftStore.getState().reset()
expect(useSnippetDraftStore.getState().snippetId).toBeUndefined()
expect(useSnippetDraftStore.getState().inputFields).toEqual([])
})
})

View File

@ -1,24 +0,0 @@
'use client'
import type { SnippetInputField } from '@/models/snippet'
import { create } from 'zustand'
type SnippetDraftState = {
snippetId?: string
inputFields: SnippetInputField[]
hydrateDraft: (payload: { snippetId: string, inputFields: SnippetInputField[] }) => void
setInputFields: (inputFields: SnippetInputField[]) => void
reset: () => void
}
const initialState = {
snippetId: undefined,
inputFields: [] as SnippetInputField[],
}
export const useSnippetDraftStore = create<SnippetDraftState>(set => ({
...initialState,
hydrateDraft: ({ snippetId, inputFields }) => set({ snippetId, inputFields }),
setInputFields: inputFields => set({ inputFields }),
reset: () => set(initialState),
}))

View File

@ -1,7 +1,7 @@
import type { SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDraftStore } from '../../draft-store'
import { useSnippetDetailStore } from '../../store'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
@ -112,7 +112,9 @@ describe('snippet/use-nodes-sync-draft', () => {
mockSetSyncWorkflowDraftHash.mockImplementation((hash: string) => {
workflowStoreState.syncWorkflowDraftHash = hash
})
useSnippetDraftStore.getState().setInputFields([createInputField('topic')])
useSnippetDetailStore.setState({
fields: [createInputField('topic')],
})
})
it('should include current input_fields when syncing the draft graph', async () => {
@ -137,18 +139,6 @@ describe('snippet/use-nodes-sync-draft', () => {
expect(mockUseNodesReadOnlyByCanEdit).toHaveBeenCalledWith(true)
})
it('should keep draft input_fields when the navigation store is reset during route leave', () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/snippets/snippet-1/workflows/draft', expect.objectContaining({
input_fields: [createInputField('topic')],
}))
})
it('should snapshot graph before queued draft sync executes', async () => {
deferSerialCallbacks = true
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))

View File

@ -31,8 +31,8 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
vi.mock('../../draft-store', () => ({
useSnippetDraftStore: {
vi.mock('../../store', () => ({
useSnippetDetailStore: {
setState: (...args: unknown[]) => mockSnippetSetState(...args),
},
}))
@ -75,7 +75,7 @@ describe('useSnippetRefreshDraft', () => {
})
expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1')
expect(mockSnippetSetState).toHaveBeenCalledWith({
inputFields: [],
fields: [],
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)

View File

@ -4,7 +4,7 @@ import { act } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDraftStore } from '../../draft-store'
import { useSnippetDetailStore } from '../../store'
import { useSnippetStartRun } from '../use-snippet-start-run'
const mockWorkflowStoreGetState = vi.fn()
@ -39,7 +39,7 @@ const inputFields: SnippetInputField[] = [
describe('useSnippetStartRun', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDraftStore.getState().reset()
useSnippetDetailStore.getState().reset()
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
showDebugAndPreviewPanel: false,
@ -51,7 +51,7 @@ describe('useSnippetStartRun', () => {
})
it('should open the debug panel and input form when snippet has input fields', () => {
useSnippetDraftStore.getState().setInputFields(inputFields)
useSnippetDetailStore.setState({ fields: inputFields })
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
@ -83,7 +83,7 @@ describe('useSnippetStartRun', () => {
})
it('should use current snippet input fields from the store before starting a run', () => {
useSnippetDraftStore.getState().setInputFields(inputFields)
useSnippetDetailStore.setState({ fields: inputFields })
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
@ -99,7 +99,7 @@ describe('useSnippetStartRun', () => {
})
it('should close the panel when debug panel is already open', () => {
useSnippetDraftStore.getState().setInputFields(inputFields)
useSnippetDetailStore.setState({ fields: inputFields })
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
@ -122,7 +122,7 @@ describe('useSnippetStartRun', () => {
})
it('should do nothing when workflow is already running', () => {
useSnippetDraftStore.getState().setInputFields(inputFields)
useSnippetDetailStore.setState({ fields: inputFields })
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: {

View File

@ -8,9 +8,8 @@ import { useNodesReadOnlyByCanEdit } from '@/app/components/workflow/hooks/use-w
import { useWorkflowStore } from '@/app/components/workflow/store'
import { API_PREFIX } from '@/config'
import { consoleClient } from '@/service/client'
// eslint-disable-next-line no-restricted-imports
import { postWithKeepalive } from '@/service/fetch'
import { useSnippetDraftStore } from '../draft-store'
import { useSnippetDetailStore } from '../store'
import { useSnippetRefreshDraft } from './use-snippet-refresh-draft'
const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => {
@ -52,7 +51,7 @@ export const useNodesSyncDraft = (snippetId: string) => {
const getInputFieldsSyncPayload = useCallback((inputFields?: SnippetInputField[]) => {
return {
input_fields: inputFields ?? useSnippetDraftStore.getState().inputFields,
input_fields: inputFields ?? useSnippetDetailStore.getState().fields,
}
}, [])

View File

@ -5,7 +5,7 @@ import { useCallback } from 'react'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { fetchSnippetDraftWorkflow } from '@/service/use-snippet-workflows'
import { useSnippetDraftStore } from '../draft-store'
import { useSnippetDetailStore } from '../store'
export const useSnippetRefreshDraft = (snippetId: string) => {
const workflowStore = useWorkflowStore()
@ -36,8 +36,8 @@ export const useSnippetRefreshDraft = (snippetId: string) => {
edges: response.graph?.edges || [],
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
} as WorkflowDataUpdater)
useSnippetDraftStore.setState({
inputFields,
useSnippetDetailStore.setState({
fields: inputFields,
})
setSyncWorkflowDraftHash(response.hash)
setDraftUpdatedAt(response.updated_at)

View File

@ -3,7 +3,7 @@ import { useCallback } from 'react'
import { useWorkflowInteractions } from '@/app/components/workflow/hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useSnippetDraftStore } from '../draft-store'
import { useSnippetDetailStore } from '../store'
type UseSnippetStartRunOptions = {
handleRun: (params: SnippetDraftRunPayload) => void
@ -38,7 +38,7 @@ export const useSnippetStartRun = ({
setShowDebugAndPreviewPanel(true)
const currentInputFields = useSnippetDraftStore.getState().inputFields
const currentInputFields = useSnippetDetailStore.getState().fields
if (currentInputFields.length > 0) {
setShowInputsPanel(true)

View File

@ -1,44 +1,31 @@
import type { SnippetDetail } from '@/models/snippet'
import type { SnippetInputField } from '@/models/snippet'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDetailStore } from '..'
const snippet: SnippetDetail = {
id: 'snippet-1',
name: 'Snippet',
description: 'Description',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
}
const createField = (variable: string): SnippetInputField => ({
label: variable,
variable,
type: PipelineInputVarType.textInput,
required: true,
})
describe('useSnippetDetailStore', () => {
beforeEach(() => {
useSnippetDetailStore.getState().reset()
})
it('should store and reset snippet navigation state', () => {
const onFieldsChange = vi.fn()
it('should store and reset snippet input fields', () => {
const fields = [
createField('topic'),
createField('audience'),
]
useSnippetDetailStore.getState().setNavigationState({
snippetId: 'snippet-1',
snippet,
readonly: false,
onFieldsChange,
})
useSnippetDetailStore.getState().setFields(fields)
expect(useSnippetDetailStore.getState()).toMatchObject({
snippetId: 'snippet-1',
snippet,
readonly: false,
onFieldsChange,
})
expect(useSnippetDetailStore.getState().fields).toEqual(fields)
useSnippetDetailStore.getState().reset()
expect(useSnippetDetailStore.getState()).toMatchObject({
readonly: true,
snippet: undefined,
snippetId: undefined,
onFieldsChange: undefined,
})
expect(useSnippetDetailStore.getState().fields).toEqual([])
})
})

View File

@ -1,29 +1,20 @@
'use client'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import type { SnippetInputField } from '@/models/snippet'
import { create } from 'zustand'
type SnippetNavigationState = {
snippet?: SnippetDetail
snippetId?: string
readonly: boolean
onFieldsChange?: (fields: SnippetInputField[]) => void
type SnippetDetailUIState = {
fields: SnippetInputField[]
setFields: (fields: SnippetInputField[]) => void
reset: () => void
}
type SnippetDetailUIState = {
setNavigationState: (state: SnippetNavigationState) => void
reset: () => void
} & SnippetNavigationState
const initialState = {
readonly: true,
snippet: undefined,
snippetId: undefined,
onFieldsChange: undefined,
fields: [] as SnippetInputField[],
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setNavigationState: state => set(state),
setFields: fields => set({ fields }),
reset: () => set(initialState),
}))

View File

@ -40,6 +40,7 @@ const createHandlers = () => ({
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowReasoning: vi.fn(),
handleWorkflowPaused: vi.fn(),
})

View File

@ -27,6 +27,7 @@ type WorkflowRunEventHandlers = {
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
handleWorkflowReasoning: NonNullable<IOtherOptions['onReasoning']>
handleWorkflowPaused: () => void
}
@ -114,6 +115,7 @@ export const createBaseWorkflowRunCallbacks = ({
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowReasoning,
handleWorkflowPaused,
} = handlers
const {
@ -244,6 +246,9 @@ export const createBaseWorkflowRunCallbacks = ({
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onReasoning: (params) => {
handleWorkflowReasoning(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
@ -325,6 +330,7 @@ export const createFinalWorkflowRunCallbacks = ({
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowReasoning,
handleWorkflowPaused,
} = handlers
const {
@ -439,6 +445,9 @@ export const createFinalWorkflowRunCallbacks = ({
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onReasoning: (params) => {
handleWorkflowReasoning(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return

View File

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

View File

@ -138,6 +138,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => {
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowReasoning,
handleWorkflowPaused,
} = useWorkflowRunEvent()
@ -326,6 +327,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => {
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowReasoning,
handleWorkflowPaused,
}
const userCallbacks = {
@ -443,7 +445,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => {
},
finalCallbacks,
)
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout])
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowReasoning, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout])
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {

View File

@ -1,6 +1,5 @@
import { ContextMenu } from '@langgenius/dify-ui/context-menu'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { fullWorkflowAccessControl } from '../hooks-store'
import { PanelContextmenu } from '../panel-contextmenu'
import { BlockEnum } from '../types'
@ -148,24 +147,6 @@ describe('PanelContextmenu', () => {
})
})
it('should hide import app on snippet canvases', async () => {
renderPanelContextmenu({
initialStoreState: {
contextMenuTarget: { type: 'panel' },
},
hooksStoreProps: {
configsMap: {
flowId: 'snippet-1',
flowType: FlowType.snippet,
fileSettings: {},
},
},
})
expect(await screen.findByText('export')).toBeInTheDocument()
expect(screen.queryByText('importApp')).not.toBeInTheDocument()
})
it('should render preview action in chat mode', async () => {
mockUseIsChatMode.mockReturnValue(true)

View File

@ -3,10 +3,8 @@ import { ContextMenu } from '@langgenius/dify-ui/context-menu'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { useNodes } from 'reactflow'
import { PipelineInputVarType } from '@/models/pipeline'
import { SelectionContextmenu } from '../selection-contextmenu'
import { useWorkflowStore } from '../store'
import { BlockEnum } from '../types'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { createEdge, createNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
@ -17,48 +15,6 @@ const mockGetNodesReadOnly = vi.fn()
const mockHandleNodesCopy = vi.fn()
const mockHandleNodesDuplicate = vi.fn()
const mockHandleNodesDelete = vi.fn()
const mockHandleCreateSnippet = vi.fn()
const mockCreateSnippetDialogRender = vi.fn()
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['snippets.create_and_modify'] as string[],
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}),
}))
vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
useCreateSnippet: () => {
const [isOpen, setIsOpen] = React.useState(false)
return {
createSnippetMutation: { isPending: false },
handleCloseCreateSnippetDialog: () => setIsOpen(false),
handleCreateSnippet: mockHandleCreateSnippet,
handleOpenCreateSnippetDialog: () => setIsOpen(true),
isCreateSnippetDialogOpen: isOpen,
isCreatingSnippet: false,
}
},
}
})
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: (props: {
isOpen: boolean
selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } }
inputFields?: Array<{ variable: string }>
}) => {
mockCreateSnippetDialogRender(props)
return props.isOpen ? <div data-testid="create-snippet-dialog" /> : null
},
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
@ -142,9 +98,6 @@ describe('SelectionContextmenu', () => {
mockHandleNodesCopy.mockReset()
mockHandleNodesDuplicate.mockReset()
mockHandleNodesDelete.mockReset()
mockHandleCreateSnippet.mockReset()
mockCreateSnippetDialogRender.mockReset()
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
})
it('should not render when selection context menu target is absent', () => {
@ -203,41 +156,7 @@ describe('SelectionContextmenu', () => {
expect(store.getState().contextMenuTarget).toBeUndefined()
})
it('should open create snippet dialog with selected graph from the top menu item', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
createNode({ id: 'n3', selected: false, position: { x: 260, y: 0 }, width: 80, height: 40 }),
]
const edges = [
createEdge({ source: 'n1', target: 'n2' }),
createEdge({ source: 'n2', target: 'n3' }),
]
const { store } = renderSelectionMenu({ nodes, edges })
act(() => {
store.setState({ contextMenuTarget: { type: 'selection' } })
})
fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(store.getState().contextMenuTarget).toBeUndefined()
const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0]
expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2'])
expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true)
expect(dialogProps.selectedGraph.edges).toHaveLength(1)
expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 })
expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({
source: 'n1',
target: 'n2',
selected: false,
}))
})
it('should hide create snippet action without snippets create-and-modify permission', async () => {
mockWorkspacePermissionKeys.value = []
it('should hide create snippet action for selected nodes', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
@ -252,76 +171,7 @@ describe('SelectionContextmenu', () => {
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
})
expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument()
})
it('should add input fields for variable references outside of the selected graph', async () => {
const nodes = [
createNode({
id: 'n1',
selected: true,
width: 80,
height: 40,
data: {
prompt_template: 'Use {{#source-node.topic#}} and {{#n2.answer#}}',
query_variable_selector: ['source-node', 'topic'],
env_reference: '{{#env.API_KEY#}}',
},
}),
createNode({
id: 'n2',
selected: true,
position: { x: 140, y: 0 },
width: 80,
height: 40,
}),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ contextMenuTarget: { type: 'selection' } })
})
fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }))
const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0]
expect(dialogProps.inputFields).toEqual([
{
label: 'topic',
variable: 'topic',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'API_KEY',
variable: 'API_KEY',
type: PipelineInputVarType.textInput,
required: true,
},
])
expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}')
expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic'])
expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}')
})
it.each([
BlockEnum.Answer,
BlockEnum.End,
BlockEnum.Start,
])('should hide create snippet when selection contains %s node', async (nodeType) => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ contextMenuTarget: { type: 'selection' } })
})
await waitFor(() => {
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
})
expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument()
expect(screen.queryByTestId('create-snippet-dialog')).not.toBeInTheDocument()
})
it('should stay hidden when only one node is selected', async () => {

View File

@ -106,6 +106,7 @@ describe('NodeSelector', () => {
await user.click(trigger)
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock')
expect(screen.queryByText('workflow.tabs.snippets')).not.toBeInTheDocument()
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.getByText('End')).toBeInTheDocument()

View File

@ -132,7 +132,7 @@ function NodeSelector({
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const disableStartTab = flowType === FlowType.snippet
const disableSnippetsTab = flowType === FlowType.snippet
const disableSnippetsTab = true
const {
activeTab,
resetActiveTab,

View File

@ -1,6 +1,6 @@
import type { Node, NodeOutPutVar, Var } from '../../types'
import { renderHook } from '@testing-library/react'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
import { PipelineInputVarType } from '@/models/pipeline'
import { FlowType } from '@/types/common'
import { BlockEnum, VarType } from '../../types'
@ -83,7 +83,7 @@ describe('useNodesAvailableVarList', () => {
vi.clearAllMocks()
mockFlowType.value = undefined
globalThis.history.pushState({}, '', '/')
useSnippetDraftStore.getState().reset()
useSnippetDetailStore.getState().reset()
mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [createNode({ id: `before-${nodeId}` })])
mockGetTreeLeafNodes.mockImplementation((nodeId: string) => [createNode({ id: `leaf-${nodeId}` })])
mockGetNodeAvailableVars.mockReturnValue(outputVars)
@ -130,7 +130,7 @@ describe('useNodesAvailableVarList', () => {
it('adds snippet input fields as virtual start variables on snippet canvases', () => {
globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate')
useSnippetDraftStore.getState().setInputFields([{
useSnippetDetailStore.getState().setFields([{
type: PipelineInputVarType.textInput,
label: 'Topic',
variable: 'topic',

View File

@ -1,7 +1,7 @@
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
import {
useIsChatMode,
useWorkflow,
@ -51,7 +51,7 @@ const useNodesAvailableVarList = (nodes: Node[], {
filterVar: () => true,
}) => {
const { t } = useTranslation()
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
const snippetInputFields = useSnippetDetailStore(s => s.fields)
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()
@ -97,7 +97,7 @@ const useNodesAvailableVarList = (nodes: Node[], {
export const useGetNodesAvailableVarList = () => {
const { t } = useTranslation()
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
const snippetInputFields = useSnippetDetailStore(s => s.fields)
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()

View File

@ -0,0 +1,61 @@
import type { ReasoningChunkResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowReasoning } from '../use-workflow-reasoning'
const reasoningChunk = (data: Partial<ReasoningChunkResponse['data']>): ReasoningChunkResponse => ({
task_id: 'task-1',
event: 'reasoning_chunk',
data: { message_id: '', reasoning: '', ...data },
})
describe('useWorkflowReasoning', () => {
it('accumulates reasoning deltas per LLM node id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: '' }),
},
})
result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'let me ', node_id: 'llm' }))
result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'think', node_id: 'llm' }))
const state = store.getState().workflowRunningData!
expect(state.reasoningContent).toEqual({ llm: 'let me think' })
expect(state.reasoningFinished).toBeFalsy()
})
it('keeps reasoning from multiple LLM nodes in separate buckets', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), {
initialStoreState: { workflowRunningData: baseRunningData({ resultText: '' }) },
})
result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'a', node_id: 'llm-1' }))
result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'b', node_id: 'llm-2' }))
expect(store.getState().workflowRunningData!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b' })
})
it('falls back to "_" when the chunk carries no node id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), {
initialStoreState: { workflowRunningData: baseRunningData({ resultText: '' }) },
})
result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'x' }))
expect(store.getState().workflowRunningData!.reasoningContent).toEqual({ _: 'x' })
})
it('marks reasoning finished on the terminal marker without appending empty text', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowReasoning(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: '', reasoningContent: { llm: 'done' } }),
},
})
result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: '', node_id: 'llm', is_final: true }))
const state = store.getState().workflowRunningData!
expect(state.reasoningContent).toEqual({ llm: 'done' })
expect(state.reasoningFinished).toBe(true)
})
})

View File

@ -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 producing LLM node so the panel can keep multiple nodes' reasoning ordered
const key = node_id || '_'
if (reasoning)
reasoningContent[key] = (reasoningContent[key] || '') + reasoning
if (is_final)
draft.reasoningFinished = true
}))
}, [workflowStore])
return {
handleWorkflowReasoning,
}
}

View File

@ -19,6 +19,7 @@ import {
useWorkflowTextChunk,
useWorkflowTextReplace,
} from '.'
import { useWorkflowReasoning } from './use-workflow-reasoning'
export const useWorkflowRunEvent = () => {
const { handleWorkflowStarted } = useWorkflowStarted()
@ -35,6 +36,7 @@ export const useWorkflowRunEvent = () => {
const { handleWorkflowNodeRetry } = useWorkflowNodeRetry()
const { handleWorkflowTextChunk } = useWorkflowTextChunk()
const { handleWorkflowTextReplace } = useWorkflowTextReplace()
const { handleWorkflowReasoning } = useWorkflowReasoning()
const { handleWorkflowAgentLog } = useWorkflowAgentLog()
const { handleWorkflowPaused } = useWorkflowPaused()
const { handleWorkflowNodeHumanInputRequired } = useWorkflowNodeHumanInputRequired()
@ -56,6 +58,7 @@ export const useWorkflowRunEvent = () => {
handleWorkflowNodeRetry,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowReasoning,
handleWorkflowAgentLog,
handleWorkflowPaused,
handleWorkflowNodeHumanInputFormFilled,

View File

@ -1,34 +0,0 @@
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { appendSnippetInputFieldVars } from '../snippet-input-field-vars'
const createNode = (id = 'node-1'): Node => ({
id,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.LLM,
title: 'Node',
desc: '',
},
} as Node)
describe('appendSnippetInputFieldVars', () => {
beforeEach(() => {
globalThis.history.pushState({}, '', '/')
})
it('should treat missing snippet input fields as empty on snippet canvases', () => {
globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate')
const availableNodes = [createNode()]
expect(appendSnippetInputFieldVars({
availableNodes,
fields: undefined,
title: 'Snippet',
})).toEqual({
availableNodes,
availableVars: [],
})
})
})

View File

@ -12,8 +12,8 @@ const mockFlowType = vi.hoisted(() => ({
value: undefined as FlowType | undefined,
}))
vi.mock('@/app/components/snippets/draft-store', () => ({
useSnippetDraftStore: (selector: (state: { inputFields: unknown[] }) => unknown) => selector({ inputFields: [] }),
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: { fields: unknown[] }) => unknown) => selector({ fields: [] }),
}))
vi.mock('@/app/components/workflow/hooks', () => ({

View File

@ -98,18 +98,17 @@ export const appendSnippetInputFieldVars = ({
title,
}: {
availableNodes: Node[]
fields?: SnippetInputField[]
fields: SnippetInputField[]
title: string
}) => {
const inputFields = fields ?? []
const shouldAppendSnippetInputFields = isSnippetCanvas()
&& inputFields.length > 0
&& fields.length > 0
&& !availableNodes.some(node => node.data.type === BlockEnum.Start)
const snippetInputFieldNode = shouldAppendSnippetInputFields
? buildSnippetInputFieldNode(inputFields, title)
? buildSnippetInputFieldNode(fields, title)
: undefined
const snippetInputFieldVars = shouldAppendSnippetInputFields
? buildSnippetInputFieldVars(inputFields, title)
? buildSnippetInputFieldVars(fields, title)
: undefined
return {

View File

@ -1,6 +1,6 @@
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
import {
useIsChatMode,
useWorkflow,
@ -34,7 +34,7 @@ const useAvailableVarList = (nodeId: string, {
filterVar: () => true,
}) => {
const { t } = useTranslation()
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
const snippetInputFields = useSnippetDetailStore(s => s.fields)
const { getTreeLeafNodes, getNodeById, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()

View File

@ -9,7 +9,6 @@ import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { FlowType } from '@/types/common'
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
import {
useDSL,
@ -19,7 +18,6 @@ import {
useWorkflowStartRun,
} from './hooks'
import { useHooksStore } from './hooks-store'
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
import AddBlock from './operator/add-block'
import { useOperator } from './operator/hooks'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
@ -50,7 +48,6 @@ export function PanelContextmenu({
const { isCommentModeAvailable } = useWorkflowMoveMode()
const { exportCheck } = useDSL()
const accessControl = useHooksStore(s => s.accessControl)
const flowType = useHooksStore(s => s.configsMap?.flowType)
const isChatMode = useIsChatMode()
const workflowOperationReadOnly = !!(
workflowRunningData?.result.status === WorkflowRunningStatus.Running
@ -60,7 +57,6 @@ export function PanelContextmenu({
)
const canEditWorkflow = accessControl.canEdit && !workflowOperationReadOnly
const canCommentWorkflow = accessControl.canComment && !workflowOperationReadOnly
const shouldHideImportApp = flowType === FlowType.snippet || isSnippetCanvas()
const renderAddBlockTrigger = useCallback(() => {
return (
@ -181,14 +177,12 @@ export function PanelContextmenu({
>
{t('export', { ns: 'app' })}
</ContextMenuItem>
{!shouldHideImportApp && (
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
)}
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
</ContextMenuGroup>
</>
)}

View File

@ -71,6 +71,12 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: unknown[] }) => <div data-testid="tracing-panel">{list.length}</div>,
}))
vi.mock('@/app/components/base/chat/chat/answer/reasoning-panel', () => ({
default: ({ content, done }: { content: Record<string, string>, done: boolean }) => (
<div data-testid="reasoning-panel" data-done={String(done)}>{Object.keys(content).join(',')}</div>
),
}))
vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({
default: ({ onRun }: { onRun: () => void }) => (
<button type="button" onClick={onRun}>
@ -341,6 +347,56 @@ 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 not render a reasoning panel when there is no reasoning content', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({ status: WorkflowRunningStatus.Running }),
}),
resultText: '',
reasoningContent: { llm: '' },
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
await user.click(screen.getByText('runLog.result'))
expect(screen.queryByTestId('reasoning-panel')).not.toBeInTheDocument()
})
it('should switch to the tracing tab when result panel requests it', async () => {
const user = userEvent.setup()

View File

@ -10,6 +10,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ReasoningPanel from '@/app/components/base/chat/chat/answer/reasoning-panel'
import Loading from '@/app/components/base/loading'
import { submitHumanInputForm } from '@/service/workflow'
import {
@ -203,6 +204,12 @@ const WorkflowPreview = () => {
humanInputFilledFormDataList={humanInputFilledFormDataList}
/>
)}
{workflowRunningData?.reasoningContent && Object.values(workflowRunningData.reasoningContent).some(Boolean) && (
<ReasoningPanel
content={workflowRunningData.reasoningContent}
done={!!workflowRunningData?.reasoningFinished || workflowRunningData?.result?.status !== WorkflowRunningStatus.Running}
/>
)}
<ResultText
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
isPaused={workflowRunningData?.result?.status === WorkflowRunningStatus.Paused}

View File

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

View File

@ -12,15 +12,11 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore } from 'reactflow'
import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection'
import { canCreateAndModifySnippets } from '@/app/components/snippets/utils/permission'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
import { useStore, useWorkflowStore } from './store'
import { BlockEnum } from './types'
const AlignType = {
Bottom: 'bottom',
@ -75,14 +71,6 @@ const menuSections: MenuSection[] = [
},
]
const unsupportedSnippetNodeTypes = new Set([
BlockEnum.Answer,
BlockEnum.End,
BlockEnum.Start,
BlockEnum.HumanInput,
BlockEnum.KnowledgeRetrieval,
])
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const childNodeIds = new Set<string>()
@ -235,7 +223,6 @@ export function SelectionContextmenu({
}) {
const { t } = useTranslation()
const { getNodesReadOnly } = useNodesReadOnly()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection')
@ -247,20 +234,8 @@ export function SelectionContextmenu({
const selectedNodes = useReactFlowStore(state =>
state.getNodes().filter(node => node.selected),
)
const edges = useReactFlowStore(state => state.edges)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const {
createSnippetDialog,
handleOpenCreateSnippet,
isCreateSnippetDialogOpen,
} = useCreateSnippetFromSelection({
edges,
selectedNodes,
onClose,
})
const canCreateSnippet = canCreateAndModifySnippets(workspacePermissionKeys)
&& selectedNodes.every(node => !unsupportedSnippetNodeTypes.has(node.data.type))
const handleCopyNodes = useCallback(() => {
handleNodesCopy()
@ -370,24 +345,11 @@ export function SelectionContextmenu({
}, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose])
if (!isSelectionContextMenu || selectedNodes.length <= 1)
return isCreateSnippetDialogOpen ? createSnippetDialog : null
return null
return (
<>
<ContextMenuContent popupClassName="w-[240px]" sideOffset={4}>
{canCreateSnippet && (
<>
<ContextMenuGroup>
<ContextMenuItem
className="px-3 text-text-secondary"
onClick={handleOpenCreateSnippet}
>
<span>{t('snippet.createDialogTitle', { defaultValue: 'Create Snippet', ns: 'workflow' })}</span>
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
</>
)}
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
@ -436,7 +398,6 @@ export function SelectionContextmenu({
</ContextMenuGroup>
))}
</ContextMenuContent>
{createSnippetDialog}
</>
)
}

View File

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

View File

@ -353,9 +353,6 @@
"roster.deleteDialog.title": "حذف {{name}}؟",
"roster.deleteFailed": "فشل حذف الوكيل.",
"roster.deleteSuccess": "تم حذف الوكيل.",
"roster.duplicateDialog.description": "أنشئ نسخة من {{name}} وخصّص هويته في Roster.",
"roster.duplicateDialog.title": "نسخ الوكيل",
"roster.duplicateForm.changeIcon": "تغيير أيقونة نسخة {{name}}",
"roster.duplicateSuccess": "تم نسخ الوكيل.",
"roster.editAgent": "تعديل {{name}}",
"roster.editDialog.description": "حدّث اسم Roster والوصف والدور لهذا الوكيل.",

View File

@ -1,4 +1,6 @@
{
"cancel": "إلغاء",
"continueEditing": "متابعة التحرير",
"create": "إنشاء مقتطف",
"createFailed": "فشل إنشاء المقتطف",
"createFrom": "إنشاء من",
@ -9,6 +11,11 @@
"deleteConfirmTitle": "هل تريد حذف المقتطف؟",
"deleteFailed": "فشل حذف المقتطف",
"deleted": "تم حذف المقتطف",
"discardChanges": "تجاهل التغييرات",
"discardChangesDescription": "سيتم تجاهل مسودّة تغييراتك وسيعود المقتطف إلى آخر نسخة محفوظة.",
"discardChangesTitle": "هل تريد تجاهل مسودة التغييرات؟",
"discardDraft": "تجاهل المسودة",
"doNotSave": "اترك كمسودة",
"draft": "مسودة",
"dslVersionMismatchDescription": "تم اكتشاف اختلاف كبير في إصدارات DSL. قد يؤدي فرض الاستيراد إلى حدوث خلل في المقتطف.",
"dslVersionMismatchQuestion": "هل تريد الاستمرار؟",
@ -17,7 +24,9 @@
"editDialogTitle": "تحرير معلومات المقتطف",
"editDone": "تم تحديث معلومات المقتطف",
"editFailed": "فشل تحديث معلومات المقتطف",
"emptyGraphSaveError": "أضف عقدة واحدة على الأقل قبل النشر.",
"editingDraft": "أنت تقوم بتحرير مسودة.",
"emptyGraphSaveError": "أضف عقدة واحدة على الأقل قبل الحفظ.",
"exitEditing": "الخروج من التحرير",
"exportFailed": "فشل تصدير المقتطف.",
"importDSLFile": "استيراد ملف دي اس ال",
"importDialogTitle": "استيراد مقتطف",
@ -43,11 +52,18 @@
"publishFailed": "فشل نشر المقتطف",
"publishMenuCurrentDraft": "المسودة الحالية غير منشورة",
"publishSuccess": "تم نشر المقتطف",
"save": "حفظ",
"saveAndExit": "حفظ والخروج",
"saveBeforeLeavingDescription": "احفظ لجعل هذا الإصدار متاحًا للاستخدام في مهام سير العمل. أو احتفظ بتعديلاتك كمسودة في الوقت الحالي.",
"saveBeforeLeavingTitle": "هل تريد حفظ التغييرات قبل المغادرة؟",
"saveSuccess": "تم حفظ المقتطف",
"sectionOrchestrate": "نسق",
"testRunButton": "تشغيل تجريبي",
"typeLabel": "مقتطف",
"unknownUser": "المستخدم",
"unsavedChanges": "لا يتم حفظ التغييرات الحالية.",
"updatedBy": "{{name}} تم التحديث {{time}}",
"usageCount": "تم الاستخدام {{count}} مرات",
"variableInspect": "فحص متغير"
"variableInspect": "فحص متغير",
"viewOnly": "عرض فقط"
}

View File

@ -353,9 +353,6 @@
"roster.deleteDialog.title": "{{name}} löschen?",
"roster.deleteFailed": "Agent konnte nicht gelöscht werden.",
"roster.deleteSuccess": "Agent gelöscht.",
"roster.duplicateDialog.description": "Erstellen Sie eine Kopie von {{name}} und passen Sie ihre Roster-Identität an.",
"roster.duplicateDialog.title": "Agent duplizieren",
"roster.duplicateForm.changeIcon": "Symbol des Duplikats für {{name}} ändern",
"roster.duplicateSuccess": "Agent dupliziert.",
"roster.editAgent": "{{name}} bearbeiten",
"roster.editDialog.description": "Aktualisieren Sie Roster-Name, Beschreibung und Rolle dieses Agenten.",

View File

@ -1,4 +1,6 @@
{
"cancel": "Abbrechen",
"continueEditing": "Bearbeiten Sie weiter",
"create": "SNIPPET ERSTELLEN",
"createFailed": "Snippet konnte nicht erstellt werden",
"createFrom": "ERSTELLEN AUS",
@ -9,6 +11,11 @@
"deleteConfirmTitle": "Snippet löschen?",
"deleteFailed": "Snippet konnte nicht gelöscht werden",
"deleted": "Snippet gelöscht",
"discardChanges": "Änderungen verwerfen",
"discardChangesDescription": "Ihre Entwurfsänderungen werden verworfen und das Snippet kehrt zur zuletzt gespeicherten Version zurück.",
"discardChangesTitle": "Entwurfsänderungen verwerfen?",
"discardDraft": "Entwurf verwerfen",
"doNotSave": "Als Entwurf belassen",
"draft": "Entwurf",
"dslVersionMismatchDescription": "Es wurde ein erheblicher Unterschied zwischen den DSL-Versionen festgestellt. Das Erzwingen des Imports kann zu Fehlfunktionen des Snippets führen.",
"dslVersionMismatchQuestion": "Möchten Sie fortfahren?",
@ -17,7 +24,9 @@
"editDialogTitle": "Bearbeiten Sie die Snippet-Informationen",
"editDone": "Snippet-Informationen aktualisiert",
"editFailed": "Snippet-Informationen konnten nicht aktualisiert werden",
"emptyGraphSaveError": "Fügen Sie vor dem Veröffentlichen mindestens einen Knoten hinzu.",
"editingDraft": "Sie bearbeiten einen Entwurf.",
"emptyGraphSaveError": "Fügen Sie vor dem Speichern mindestens einen Knoten hinzu.",
"exitEditing": "Bearbeiten beenden",
"exportFailed": "Der Export des Snippets ist fehlgeschlagen.",
"importDSLFile": "DSL-Datei importieren",
"importDialogTitle": "Snippet importieren",
@ -43,11 +52,18 @@
"publishFailed": "Snippet konnte nicht veröffentlicht werden",
"publishMenuCurrentDraft": "Aktueller Entwurf unveröffentlicht",
"publishSuccess": "Snippet veröffentlicht",
"save": "Speichern",
"saveAndExit": "Speichern und beenden",
"saveBeforeLeavingDescription": "Speichern Sie, um diese Version für die Verwendung in Workflows verfügbar zu machen. Oder bewahren Sie Ihre Änderungen vorerst als Entwurf auf.",
"saveBeforeLeavingTitle": "Änderungen vor dem Verlassen speichern?",
"saveSuccess": "Snippet gespeichert",
"sectionOrchestrate": "Orchestrieren",
"testRunButton": "Testlauf",
"typeLabel": "Ausschnitt",
"unknownUser": "Benutzer",
"unsavedChanges": "Aktuelle Änderungen werden nicht gespeichert.",
"updatedBy": "{{name}} aktualisiert {{time}}",
"usageCount": "{{count}} Mal verwendet",
"variableInspect": "Variablenprüfung"
"variableInspect": "Variablenprüfung",
"viewOnly": "Nur ansehen"
}

View File

@ -1,4 +1,6 @@
{
"cancel": "Cancel",
"continueEditing": "Continue Editing",
"create": "CREATE SNIPPET",
"createFailed": "Failed to create snippet",
"createFrom": "CREATE FROM",
@ -9,6 +11,11 @@
"deleteConfirmTitle": "Delete Snippet?",
"deleteFailed": "Failed to delete snippet",
"deleted": "Snippet deleted",
"discardChanges": "Discard Changes",
"discardChangesDescription": "Your draft changes will be discarded and the snippet will return to the last saved version.",
"discardChangesTitle": "Discard draft changes?",
"discardDraft": "Discard Draft",
"doNotSave": "Leave as Draft",
"draft": "Draft",
"dslVersionMismatchDescription": "A significant difference in DSL versions has been detected. Forcing the import may cause the snippet to malfunction.",
"dslVersionMismatchQuestion": "Do you want to continue?",
@ -17,7 +24,9 @@
"editDialogTitle": "Edit Snippet Info",
"editDone": "Snippet info updated",
"editFailed": "Failed to update snippet info",
"emptyGraphSaveError": "Add at least one node before publishing.",
"editingDraft": "You are editing a draft.",
"emptyGraphSaveError": "Add at least one node before saving.",
"exitEditing": "Exit Editing",
"exportFailed": "Export snippet failed.",
"importDSLFile": "Import DSL file",
"importDialogTitle": "Import Snippet",
@ -43,11 +52,18 @@
"publishFailed": "Failed to publish snippet",
"publishMenuCurrentDraft": "Current draft unpublished",
"publishSuccess": "Snippet published",
"save": "Save",
"saveAndExit": "Save and Exit",
"saveBeforeLeavingDescription": "Save to make this version available to use in workflows. Or keep your edits as a draft for now.",
"saveBeforeLeavingTitle": "Save changes before leaving?",
"saveSuccess": "Snippet saved",
"sectionOrchestrate": "Orchestrate",
"testRunButton": "Test run",
"typeLabel": "Snippet",
"unknownUser": "User",
"unsavedChanges": "Current changes are not saved.",
"updatedBy": "{{name}} updated {{time}}",
"usageCount": "Used {{count}} times",
"variableInspect": "Variable Inspect"
"variableInspect": "Variable Inspect",
"viewOnly": "View only"
}

View File

@ -353,9 +353,6 @@
"roster.deleteDialog.title": "¿Eliminar {{name}}?",
"roster.deleteFailed": "Error al eliminar el agente.",
"roster.deleteSuccess": "Agente eliminado.",
"roster.duplicateDialog.description": "Crea una copia de {{name}} y personaliza su identidad en el roster.",
"roster.duplicateDialog.title": "Duplicar agente",
"roster.duplicateForm.changeIcon": "Cambiar icono del duplicado de {{name}}",
"roster.duplicateSuccess": "Agente duplicado.",
"roster.editAgent": "Editar {{name}}",
"roster.editDialog.description": "Actualiza el nombre, la descripción y el rol del roster para este agente.",

View File

@ -1,4 +1,6 @@
{
"cancel": "Cancelar",
"continueEditing": "Continuar editando",
"create": "CREAR FRAGMENTO",
"createFailed": "No se pudo crear el fragmento",
"createFrom": "CREAR DESDE",
@ -9,6 +11,11 @@
"deleteConfirmTitle": "¿Eliminar fragmento?",
"deleteFailed": "No se pudo eliminar el fragmento",
"deleted": "Fragmento eliminado",
"discardChanges": "Descartar cambios",
"discardChangesDescription": "Los cambios en el borrador se descartarán y el fragmento volverá a la última versión guardada.",
"discardChangesTitle": "¿Descartar cambios en el borrador?",
"discardDraft": "Descartar borrador",
"doNotSave": "Dejar como borrador",
"draft": "Borrador",
"dslVersionMismatchDescription": "Se ha detectado una diferencia significativa en las versiones DSL. Forzar la importación puede provocar que el fragmento no funcione correctamente.",
"dslVersionMismatchQuestion": "¿Quieres continuar?",
@ -17,7 +24,9 @@
"editDialogTitle": "Editar información del fragmento",
"editDone": "Información del fragmento actualizada",
"editFailed": "No se pudo actualizar la información del fragmento",
"emptyGraphSaveError": "Agregue al menos un nodo antes de publicar.",
"editingDraft": "Estás editando un borrador.",
"emptyGraphSaveError": "Agregue al menos un nodo antes de guardar.",
"exitEditing": "Salir de edición",
"exportFailed": "Error al exportar el fragmento.",
"importDSLFile": "Importar archivo DSL",
"importDialogTitle": "Importar fragmento",
@ -43,11 +52,18 @@
"publishFailed": "No se pudo publicar el fragmento",
"publishMenuCurrentDraft": "Borrador actual inédito",
"publishSuccess": "Fragmento publicado",
"save": "Guardar",
"saveAndExit": "Guardar y salir",
"saveBeforeLeavingDescription": "Guárdelo para que esta versión esté disponible para su uso en flujos de trabajo. O mantén tus ediciones como borrador por ahora.",
"saveBeforeLeavingTitle": "¿Guardar cambios antes de salir?",
"saveSuccess": "Fragmento guardado",
"sectionOrchestrate": "orquestar",
"testRunButton": "Ejecución de prueba",
"typeLabel": "Fragmento",
"unknownUser": "Usuario",
"unsavedChanges": "Los cambios actuales no se guardan.",
"updatedBy": "{{name}} actualizado {{time}}",
"usageCount": "Usado {{count}} veces",
"variableInspect": "Inspección de variables"
"variableInspect": "Inspección de variables",
"viewOnly": "Ver sólo"
}

View File

@ -353,9 +353,6 @@
"roster.deleteDialog.title": "حذف {{name}}؟",
"roster.deleteFailed": "حذف عامل ناموفق بود.",
"roster.deleteSuccess": "عامل حذف شد.",
"roster.duplicateDialog.description": "یک کپی از {{name}} ایجاد کنید و هویت آن در Roster را سفارشی کنید.",
"roster.duplicateDialog.title": "تکثیر عامل",
"roster.duplicateForm.changeIcon": "تغییر آیکون کپی برای {{name}}",
"roster.duplicateSuccess": "عامل تکثیر شد.",
"roster.editAgent": "ویرایش {{name}}",
"roster.editDialog.description": "نام، توضیحات و نقش Roster این عامل را به‌روزرسانی کنید.",

View File

@ -1,4 +1,6 @@
{
"cancel": "لغو کنید",
"continueEditing": "ادامه ویرایش",
"create": "ایجاد قطعه",
"createFailed": "قطعه ایجاد نشد",
"createFrom": "ایجاد از",
@ -9,6 +11,11 @@
"deleteConfirmTitle": "قطعه حذف شود؟",
"deleteFailed": "قطعه حذف نشد",
"deleted": "قطعه حذف شد",
"discardChanges": "حذف تغییرات",
"discardChangesDescription": "تغییرات پیش نویس شما نادیده گرفته می شود و قطعه به آخرین نسخه ذخیره شده باز می گردد.",
"discardChangesTitle": "از تغییرات پیش‌نویس صرف‌نظر شود؟",
"discardDraft": "دور انداختن پیش نویس",
"doNotSave": "به عنوان پیش نویس بگذارید",
"draft": "پیش نویس",
"dslVersionMismatchDescription": "تفاوت قابل توجهی در نسخه های DSL شناسایی شده است. وارد کردن اجباری ممکن است باعث اختلال در عملکرد قطعه شود.",
"dslVersionMismatchQuestion": "آیا می خواهید ادامه دهید؟",
@ -17,7 +24,9 @@
"editDialogTitle": "ویرایش اطلاعات قطعه",
"editDone": "اطلاعات قطعه به‌روزرسانی شد",
"editFailed": "اطلاعات قطعه به‌روزرسانی نشد",
"emptyGraphSaveError": "قبل از انتشار حداقل یک گره اضافه کنید.",
"editingDraft": "شما در حال ویرایش پیش نویس هستید.",
"emptyGraphSaveError": "قبل از ذخیره حداقل یک گره اضافه کنید.",
"exitEditing": "خروج از ویرایش",
"exportFailed": "قطعه صادر نشد.",
"importDSLFile": "فایل DSL را وارد کنید",
"importDialogTitle": "وارد کردن قطعه",
@ -43,11 +52,18 @@
"publishFailed": "انتشار قطعه ناموفق بود",
"publishMenuCurrentDraft": "پیش نویس فعلی منتشر نشده است",
"publishSuccess": "قطعه منتشر شد",
"save": "ذخیره کنید",
"saveAndExit": "ذخیره و خروج",
"saveBeforeLeavingDescription": "ذخیره کنید تا این نسخه برای استفاده در گردش کار در دسترس باشد. یا ویرایش های خود را در حال حاضر به عنوان پیش نویس نگه دارید.",
"saveBeforeLeavingTitle": "تغییرات قبل از خروج ذخیره شود؟",
"saveSuccess": "قطعه ذخیره شد",
"sectionOrchestrate": "ارکستر کردن",
"testRunButton": "اجرای آزمایشی",
"typeLabel": "قطعه",
"unknownUser": "کاربر",
"unsavedChanges": "تغییرات فعلی ذخیره نمی شوند.",
"updatedBy": "{{name}} به روز شد {{time}}",
"usageCount": "{{count}} بار استفاده شده است",
"variableInspect": "متغیر بازرسی"
"variableInspect": "متغیر بازرسی",
"viewOnly": "فقط مشاهده کنید"
}

View File

@ -353,9 +353,6 @@
"roster.deleteDialog.title": "Supprimer {{name}} ?",
"roster.deleteFailed": "Échec de la suppression de lagent.",
"roster.deleteSuccess": "Agent supprimé.",
"roster.duplicateDialog.description": "Créez une copie de {{name}} et personnalisez son identité dans le roster.",
"roster.duplicateDialog.title": "Dupliquer lagent",
"roster.duplicateForm.changeIcon": "Changer licône du duplicata pour {{name}}",
"roster.duplicateSuccess": "Agent dupliqué.",
"roster.editAgent": "Modifier {{name}}",
"roster.editDialog.description": "Mettez à jour le nom, la description et le rôle de cet agent dans le roster.",

View File

@ -1,4 +1,6 @@
{
"cancel": "Annuler",
"continueEditing": "Continuer la modification",
"create": "CRÉER UN EXTRAIT",
"createFailed": "Échec de la création de l'extrait",
"createFrom": "CRÉER À PARTIR DE",
@ -9,6 +11,11 @@
"deleteConfirmTitle": "Supprimer l'extrait ?",
"deleteFailed": "Échec de la suppression de l'extrait",
"deleted": "Extrait supprimé",
"discardChanges": "Ignorer les modifications",
"discardChangesDescription": "Vos brouillons de modifications seront ignorés et l'extrait reviendra la dernière version enregistrée.",
"discardChangesTitle": "Supprimer les brouillons de modifications ?",
"discardDraft": "Supprimer le brouillon",
"doNotSave": "Laisser comme brouillon",
"draft": "Brouillon",
"dslVersionMismatchDescription": "Une différence significative dans les versions DSL a été détectée. Forcer limportation peut entraîner un dysfonctionnement de lextrait.",
"dslVersionMismatchQuestion": "Voulez-vous continuer ?",
@ -17,7 +24,9 @@
"editDialogTitle": "Modifier les informations sur l'extrait",
"editDone": "Informations sur l'extrait mises jour",
"editFailed": "Échec de la mise jour des informations sur l'extrait",
"emptyGraphSaveError": "Ajoutez au moins un nœud avant de publier.",
"editingDraft": "Vous modifiez un brouillon.",
"emptyGraphSaveError": "Ajoutez au moins un nœud avant d'enregistrer.",
"exitEditing": "Quitter l'édition",
"exportFailed": "Échec de l'exportation de l'extrait.",
"importDSLFile": "Importer un fichier DSL",
"importDialogTitle": "Importer un extrait",
@ -43,11 +52,18 @@
"publishFailed": "Échec de la publication de l'extrait",
"publishMenuCurrentDraft": "Projet actuel non publié",
"publishSuccess": "Extrait publié",
"save": "Enregistrer",
"saveAndExit": "Enregistrer et quitter",
"saveBeforeLeavingDescription": "Enregistrez pour rendre cette version disponible pour une utilisation dans les flux de travail. Ou conservez vos modifications sous forme de brouillon pour le moment.",
"saveBeforeLeavingTitle": "Enregistrer les modifications avant de quitter ?",
"saveSuccess": "Extrait enregistré",
"sectionOrchestrate": "Orchestrer",
"testRunButton": "Exécution d'essai",
"typeLabel": "Extrait",
"unknownUser": "Utilisateur",
"unsavedChanges": "Les modifications actuelles ne sont pas enregistrées.",
"updatedBy": "{{name}} mis jour {{time}}",
"usageCount": "Utilisé {{count}} fois",
"variableInspect": "Inspecter les variables"
"variableInspect": "Inspecter les variables",
"viewOnly": "Visualisation uniquement"
}

Some files were not shown because too many files have changed in this diff Show More