mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 08:08:12 +08:00
Compare commits
8 Commits
deploy/dev
...
feat/follo
| Author | SHA1 | Date | |
|---|---|---|---|
| c367f77069 | |||
| b3154f1bbd | |||
| fe1fb88c54 | |||
| ce0d67fd53 | |||
| 5d604b32dc | |||
| ac06deba20 | |||
| 725e4da29d | |||
| c2a554da93 |
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -29,6 +29,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueNodeExceptionEvent,
|
||||
QueueNodeFailedEvent,
|
||||
QueuePingEvent,
|
||||
QueueReasoningChunkEvent,
|
||||
QueueRetrieverResourcesEvent,
|
||||
QueueStopEvent,
|
||||
QueueTextChunkEvent,
|
||||
@ -46,6 +47,7 @@ from core.app.entities.task_entities import (
|
||||
MessageAudioStreamResponse,
|
||||
MessageEndStreamResponse,
|
||||
PingStreamResponse,
|
||||
ReasoningChunkStreamResponse,
|
||||
)
|
||||
from core.base.tts.app_generator_tts_publisher import AudioTrunk
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
@ -196,6 +198,42 @@ class TestAdvancedChatGenerateTaskPipeline:
|
||||
assert pipeline._task_state.answer == "hi"
|
||||
assert responses
|
||||
|
||||
def test_handle_reasoning_chunk_event_emits_on_nonempty(self):
|
||||
pipeline = _make_pipeline()
|
||||
event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False)
|
||||
|
||||
responses = list(pipeline._handle_reasoning_chunk_event(event))
|
||||
|
||||
assert len(responses) == 1
|
||||
response = responses[0]
|
||||
assert isinstance(response, ReasoningChunkStreamResponse)
|
||||
assert response.data.message_id == pipeline._message_id
|
||||
assert response.data.reasoning == "pondering"
|
||||
assert response.data.node_id == "llm-1"
|
||||
assert response.data.is_final is False
|
||||
# reasoning never touches the answer stream
|
||||
assert pipeline._task_state.answer == ""
|
||||
|
||||
def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self):
|
||||
pipeline = _make_pipeline()
|
||||
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False)
|
||||
|
||||
responses = list(pipeline._handle_reasoning_chunk_event(event))
|
||||
|
||||
assert responses == []
|
||||
|
||||
def test_handle_reasoning_chunk_event_emits_empty_final_marker(self):
|
||||
pipeline = _make_pipeline()
|
||||
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True)
|
||||
|
||||
responses = list(pipeline._handle_reasoning_chunk_event(event))
|
||||
|
||||
assert len(responses) == 1
|
||||
response = responses[0]
|
||||
assert isinstance(response, ReasoningChunkStreamResponse)
|
||||
assert response.data.reasoning == ""
|
||||
assert response.data.is_final is True
|
||||
|
||||
def test_listen_audio_msg_returns_audio_stream(self):
|
||||
pipeline = _make_pipeline()
|
||||
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
|
||||
@ -319,6 +357,43 @@ class TestAdvancedChatGenerateTaskPipeline:
|
||||
assert responses == ["done"]
|
||||
assert pipeline._recorded_files
|
||||
|
||||
def test_handle_node_succeeded_event_records_llm_reasoning(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: []
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
|
||||
pipeline._save_output_for_event = lambda event, node_execution_id: None
|
||||
|
||||
event = SimpleNamespace(
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
outputs={"reasoning_content": "first pass "},
|
||||
node_execution_id="exec",
|
||||
node_id="llm-1",
|
||||
)
|
||||
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
assert pipeline._task_state.metadata.reasoning == {"llm-1": "first pass "}
|
||||
|
||||
def test_handle_node_succeeded_event_accumulates_reasoning_across_passes(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: []
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
|
||||
pipeline._save_output_for_event = lambda event, node_execution_id: None
|
||||
|
||||
def _llm_event(reasoning: str):
|
||||
return SimpleNamespace(
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
outputs={"reasoning_content": reasoning},
|
||||
node_execution_id="exec",
|
||||
node_id="llm-1",
|
||||
)
|
||||
|
||||
# Same node id across iteration/loop passes must accumulate, not overwrite.
|
||||
list(pipeline._handle_node_succeeded_event(_llm_event("pass one ")))
|
||||
list(pipeline._handle_node_succeeded_event(_llm_event("pass two")))
|
||||
|
||||
assert pipeline._task_state.metadata.reasoning == {"llm-1": "pass one pass two"}
|
||||
|
||||
def test_iteration_and_loop_handlers(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._workflow_run_id = "run-id"
|
||||
|
||||
@ -16,6 +16,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueNodeFailedEvent,
|
||||
QueueNodeRetryEvent,
|
||||
QueueNodeSucceededEvent,
|
||||
QueueReasoningChunkEvent,
|
||||
QueueTextChunkEvent,
|
||||
QueueWorkflowPausedEvent,
|
||||
QueueWorkflowStartedEvent,
|
||||
@ -34,6 +35,7 @@ from graphon.graph_events import (
|
||||
NodeRunHumanInputFormFilledEvent,
|
||||
NodeRunIterationSucceededEvent,
|
||||
NodeRunLoopFailedEvent,
|
||||
NodeRunReasoningChunkEvent,
|
||||
NodeRunRetryEvent,
|
||||
NodeRunStartedEvent,
|
||||
NodeRunStreamChunkEvent,
|
||||
@ -395,6 +397,17 @@ class TestWorkflowBasedAppRunner:
|
||||
is_final=False,
|
||||
),
|
||||
)
|
||||
runner._handle_event(
|
||||
workflow_entry,
|
||||
NodeRunReasoningChunkEvent(
|
||||
id="exec",
|
||||
node_id="node",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
selector=["node", "reasoning_content"],
|
||||
chunk="thinking",
|
||||
is_final=False,
|
||||
),
|
||||
)
|
||||
runner._handle_event(
|
||||
workflow_entry,
|
||||
NodeRunAgentLogEvent(
|
||||
@ -442,6 +455,7 @@ class TestWorkflowBasedAppRunner:
|
||||
)
|
||||
|
||||
assert any(isinstance(event, QueueTextChunkEvent) for event in published)
|
||||
assert any(isinstance(event, QueueReasoningChunkEvent) for event in published)
|
||||
assert any(isinstance(event, QueueAgentLogEvent) for event in published)
|
||||
assert any(isinstance(event, QueueIterationCompletedEvent) for event in published)
|
||||
assert any(isinstance(event, QueueLoopCompletedEvent) for event in published)
|
||||
|
||||
@ -26,6 +26,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueNodeStartedEvent,
|
||||
QueueNodeSucceededEvent,
|
||||
QueuePingEvent,
|
||||
QueueReasoningChunkEvent,
|
||||
QueueStopEvent,
|
||||
QueueTextChunkEvent,
|
||||
QueueWorkflowFailedEvent,
|
||||
@ -40,6 +41,7 @@ from core.app.entities.task_entities import (
|
||||
MessageAudioEndStreamResponse,
|
||||
MessageAudioStreamResponse,
|
||||
PingStreamResponse,
|
||||
ReasoningChunkStreamResponse,
|
||||
WorkflowAppPausedBlockingResponse,
|
||||
WorkflowFinishStreamResponse,
|
||||
WorkflowStartStreamResponse,
|
||||
@ -265,6 +267,41 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
assert responses[0].data.text == "hi"
|
||||
assert published == [queue_message]
|
||||
|
||||
def test_handle_reasoning_chunk_event_emits_on_nonempty(self):
|
||||
pipeline = _make_pipeline()
|
||||
event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False)
|
||||
|
||||
responses = list(pipeline._handle_reasoning_chunk_event(event))
|
||||
|
||||
assert len(responses) == 1
|
||||
response = responses[0]
|
||||
assert isinstance(response, ReasoningChunkStreamResponse)
|
||||
# workflow runs have no message, so the id is omitted
|
||||
assert response.data.message_id is None
|
||||
assert response.data.reasoning == "pondering"
|
||||
assert response.data.node_id == "llm-1"
|
||||
assert response.data.is_final is False
|
||||
|
||||
def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self):
|
||||
pipeline = _make_pipeline()
|
||||
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False)
|
||||
|
||||
responses = list(pipeline._handle_reasoning_chunk_event(event))
|
||||
|
||||
assert responses == []
|
||||
|
||||
def test_handle_reasoning_chunk_event_emits_empty_final_marker(self):
|
||||
pipeline = _make_pipeline()
|
||||
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True)
|
||||
|
||||
responses = list(pipeline._handle_reasoning_chunk_event(event))
|
||||
|
||||
assert len(responses) == 1
|
||||
response = responses[0]
|
||||
assert isinstance(response, ReasoningChunkStreamResponse)
|
||||
assert response.data.reasoning == ""
|
||||
assert response.data.is_final is True
|
||||
|
||||
def test_dispatch_event_handles_node_failed(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
|
||||
|
||||
@ -29,7 +29,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
'workspace': Flags.string({ description: 'workspace id override' }),
|
||||
'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }),
|
||||
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }),
|
||||
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
|
||||
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline <think>...</think> blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }),
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { collect, HitlPauseError } from '@/commands/run/app/sse-collector'
|
||||
import { formatted, stringifyOutput } from '@/framework/output'
|
||||
import { handle, unhandle } from '@/sys/index'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { reasoningBlocksFromMetadata } from '@/sys/io/reasoning'
|
||||
import { startSpinner } from '@/sys/io/spinner'
|
||||
import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks } from '@/sys/io/think-filter'
|
||||
|
||||
@ -99,6 +100,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)) {
|
||||
|
||||
@ -35,7 +35,7 @@ export default class RunApp extends DifyCommand {
|
||||
'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }),
|
||||
'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
|
||||
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),
|
||||
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
|
||||
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline <think>...</think> blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }),
|
||||
'retry-on-limit': Flags.boolean({ description: 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', default: false }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
|
||||
|
||||
@ -203,6 +203,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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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> = {}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 !== '') {
|
||||
|
||||
92
cli/src/sys/io/reasoning.test.ts
Normal file
92
cli/src/sys/io/reasoning.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
91
cli/src/sys/io/reasoning.ts
Normal file
91
cli/src/sys/io/reasoning.ts
Normal 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)
|
||||
}
|
||||
@ -67,3 +67,15 @@ DIFY_E2E_PASSWORD=
|
||||
# DIFY_E2E_HITL_SINGLE_ACTION_APP_ID=
|
||||
# DIFY_E2E_HITL_MULTI_NODE_APP_ID=
|
||||
# DIFY_E2E_WS2_APP_ID=
|
||||
|
||||
# ── Separated-mode reasoning suite (opt-in) ─────────────────────────────────
|
||||
# run-app-reasoning.e2e.ts is skipped unless DIFY_E2E_REASONING_APP_ID resolves.
|
||||
# It needs a chatflow whose LLM node uses reasoning_format=separated AND a
|
||||
# workspace with a default chat model configured.
|
||||
#
|
||||
# Either point at an existing app:
|
||||
# DIFY_E2E_REASONING_APP_ID=
|
||||
#
|
||||
# …or auto-provision reasoning-chat.yml (→ app name "reasoning-bot"). Off by
|
||||
# default so the shared bootstrap stays free of any model dependency.
|
||||
# DIFY_E2E_REASONING_PROVISION=1
|
||||
|
||||
@ -39,11 +39,12 @@ test/e2e/
|
||||
│ ├── describe-app.e2e.ts — describe app
|
||||
│ └── get-app-all-workspaces.e2e.ts — get app -A ([EE] multi-workspace cases)
|
||||
└── run/
|
||||
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
|
||||
│ conversation, CI mode
|
||||
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
|
||||
├── run-app-file.e2e.ts — --file upload (local + remote URL)
|
||||
└── run-app-hitl.e2e.ts — HITL pause + resume
|
||||
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
|
||||
│ conversation, CI mode
|
||||
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
|
||||
├── run-app-file.e2e.ts — --file upload (local + remote URL)
|
||||
├── run-app-reasoning.e2e.ts — separated-mode reasoning (--think); opt-in
|
||||
└── run-app-hitl.e2e.ts — HITL pause + resume
|
||||
```
|
||||
|
||||
## Edition support
|
||||
@ -137,6 +138,24 @@ global-setup will:
|
||||
| `DIFY_E2E_HITL_SINGLE_ACTION_APP_ID` | |
|
||||
| `DIFY_E2E_HITL_MULTI_NODE_APP_ID` | |
|
||||
| `DIFY_E2E_WS2_APP_ID` | Override secondary workspace app ID (EE) |
|
||||
| `DIFY_E2E_REASONING_APP_ID` | separated-reasoning chatflow app ID (opt-in) |
|
||||
| `DIFY_E2E_REASONING_PROVISION` | `1` → auto-provision `reasoning-chat.yml` |
|
||||
|
||||
### Separated-mode reasoning suite (opt-in)
|
||||
|
||||
`run-app-reasoning.e2e.ts` verifies the out-of-band `reasoning_chunk` channel
|
||||
(PR #37460): `--think` surfaces the chain-of-thought to stderr framed as
|
||||
`<think>…</think>`, the answer stays clean, and `-o json` persists it under
|
||||
`metadata.reasoning`. It is **skipped** unless `DIFY_E2E_REASONING_APP_ID`
|
||||
resolves, because it runs a real LLM node and needs:
|
||||
|
||||
1. a chatflow whose LLM node uses `reasoning_format: separated`, and
|
||||
1. a workspace with a default chat model configured.
|
||||
|
||||
Point `DIFY_E2E_REASONING_APP_ID` at such an app, or set
|
||||
`DIFY_E2E_REASONING_PROVISION=1` to import the `reasoning-chat.yml` fixture
|
||||
(its system prompt forces a `<think>` block, so any chat model triggers the
|
||||
separated path — no dedicated reasoning model required).
|
||||
|
||||
## Running tests
|
||||
|
||||
|
||||
120
cli/test/e2e/fixtures/apps/reasoning-chat.yml
Normal file
120
cli/test/e2e/fixtures/apps/reasoning-chat.yml
Normal file
@ -0,0 +1,120 @@
|
||||
# Chatflow that exercises separated-mode reasoning (PR #37460): the LLM node sets
|
||||
# reasoning_format=separated, so the server strips <think>...</think> from the
|
||||
# answer and streams the chain-of-thought on the out-of-band `reasoning_chunk`
|
||||
# channel instead. The system prompt forces a <think> block, so the separated
|
||||
# path triggers with any chat model — no dedicated reasoning model required.
|
||||
#
|
||||
# NOTE: the LLM node leaves model.provider/name empty and relies on the target
|
||||
# workspace's configured default chat model. The run-app-reasoning E2E suite is
|
||||
# gated on DIFY_E2E_REASONING_APP_ID, so it is skipped unless a server with a
|
||||
# working model is wired up.
|
||||
app:
|
||||
description: e2e-test reasoning (separated mode)
|
||||
icon: 🧠
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: advanced-chat
|
||||
name: reasoning-bot
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies: []
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload: {}
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- id: start-llm
|
||||
source: '1755189262236'
|
||||
sourceHandle: source
|
||||
target: llm
|
||||
targetHandle: target
|
||||
- id: llm-answer
|
||||
source: llm
|
||||
sourceHandle: source
|
||||
target: answer
|
||||
targetHandle: target
|
||||
nodes:
|
||||
- data:
|
||||
desc: ''
|
||||
title: Start
|
||||
type: start
|
||||
variables: []
|
||||
id: '1755189262236'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
- data:
|
||||
context:
|
||||
enabled: false
|
||||
variable_selector: []
|
||||
desc: ''
|
||||
memory:
|
||||
query_prompt_template: '{{#sys.query#}}'
|
||||
window:
|
||||
enabled: false
|
||||
size: 10
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: ''
|
||||
provider: ''
|
||||
prompt_template:
|
||||
- role: system
|
||||
text: >-
|
||||
You are a helpful assistant. Always reason step by step INSIDE a
|
||||
single <think>...</think> block first, then write the final
|
||||
answer AFTER the closing </think> tag. The final answer must not
|
||||
contain any <think> tags.
|
||||
reasoning_format: separated
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
variables: []
|
||||
vision:
|
||||
enabled: false
|
||||
id: llm
|
||||
position:
|
||||
x: 380
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
- data:
|
||||
answer: '{{#llm.text#}}'
|
||||
desc: ''
|
||||
title: Answer
|
||||
type: answer
|
||||
variables: []
|
||||
id: answer
|
||||
position:
|
||||
x: 680
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
viewport:
|
||||
x: 0
|
||||
y: 0
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
@ -37,6 +37,9 @@
|
||||
* DIFY_E2E_HITL_EXTERNAL_APP_ID
|
||||
* DIFY_E2E_HITL_SINGLE_ACTION_APP_ID
|
||||
* DIFY_E2E_HITL_MULTI_NODE_APP_ID
|
||||
* DIFY_E2E_REASONING_APP_ID Override separated-reasoning chatflow app ID
|
||||
* DIFY_E2E_REASONING_PROVISION=1 Opt in to auto-provisioning reasoning-chat.yml
|
||||
* (needs a workspace default chat model)
|
||||
*/
|
||||
|
||||
/** Supported edition values. */
|
||||
@ -74,6 +77,12 @@ export type E2EEnv = {
|
||||
fileAppId: string
|
||||
/** Chat app (advanced-chat) with a file input variable */
|
||||
fileChatAppId: string
|
||||
/**
|
||||
* Chatflow whose LLM node uses reasoning_format=separated. Empty unless
|
||||
* DIFY_E2E_REASONING_APP_ID is set or the fixture is auto-provisioned; the
|
||||
* run-app-reasoning suite is skipped when empty.
|
||||
*/
|
||||
reasoningAppId: string
|
||||
/**
|
||||
* Secondary workspace ID — EE only ("auto_test1").
|
||||
* Empty in CE mode (CE has a single workspace).
|
||||
@ -118,6 +127,7 @@ export type E2ECapabilities = {
|
||||
workflowAppId: string
|
||||
fileAppId: string
|
||||
fileChatAppId: string
|
||||
reasoningAppId: string
|
||||
hitlAppId: string
|
||||
hitlExternalAppId: string
|
||||
hitlSingleActionAppId: string
|
||||
@ -171,6 +181,7 @@ export function loadE2EEnv(): E2EEnv {
|
||||
hitlMultiNodeAppId: process.env.DIFY_E2E_HITL_MULTI_NODE_APP_ID ?? '',
|
||||
fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '',
|
||||
fileChatAppId: process.env.DIFY_E2E_FILE_CHAT_APP_ID ?? '',
|
||||
reasoningAppId: process.env.DIFY_E2E_REASONING_APP_ID ?? '',
|
||||
ws2Id: process.env.DIFY_E2E_WS2_ID ?? '',
|
||||
ws2AppId: process.env.DIFY_E2E_WS2_APP_ID ?? '',
|
||||
email: process.env.DIFY_E2E_EMAIL!,
|
||||
@ -206,6 +217,7 @@ export function resolveEnv(caps: E2ECapabilities | undefined): E2EEnv {
|
||||
workflowAppId: caps.workflowAppId || env.workflowAppId,
|
||||
fileAppId: caps.fileAppId || env.fileAppId,
|
||||
fileChatAppId: caps.fileChatAppId || env.fileChatAppId,
|
||||
reasoningAppId: caps.reasoningAppId || env.reasoningAppId,
|
||||
hitlAppId: caps.hitlAppId || env.hitlAppId,
|
||||
hitlExternalAppId: caps.hitlExternalAppId || env.hitlExternalAppId,
|
||||
hitlSingleActionAppId: caps.hitlSingleActionAppId || env.hitlSingleActionAppId,
|
||||
|
||||
@ -182,6 +182,7 @@ export async function setup(project: TestProject): Promise<void> {
|
||||
workflowAppId: '',
|
||||
fileAppId: '',
|
||||
fileChatAppId: '',
|
||||
reasoningAppId: '',
|
||||
hitlAppId: '',
|
||||
hitlExternalAppId: '',
|
||||
hitlSingleActionAppId: '',
|
||||
@ -288,6 +289,7 @@ export async function setup(project: TestProject): Promise<void> {
|
||||
workflowAppId: provisionedIds.DIFY_E2E_WORKFLOW_APP_ID || E.workflowAppId,
|
||||
fileAppId: provisionedIds.DIFY_E2E_FILE_APP_ID || E.fileAppId,
|
||||
fileChatAppId: provisionedIds.DIFY_E2E_FILE_CHAT_APP_ID || E.fileChatAppId,
|
||||
reasoningAppId: provisionedIds.DIFY_E2E_REASONING_APP_ID || E.reasoningAppId,
|
||||
hitlAppId: provisionedIds.DIFY_E2E_HITL_APP_ID || E.hitlAppId,
|
||||
hitlExternalAppId: provisionedIds.DIFY_E2E_HITL_EXTERNAL_APP_ID || E.hitlExternalAppId,
|
||||
hitlSingleActionAppId: provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId,
|
||||
@ -503,6 +505,12 @@ async function provisionApps(
|
||||
['hitl-single-action.yml', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', primaryWsId],
|
||||
['hitl-multi-node.yml', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', primaryWsId],
|
||||
['file-chat.yml', 'DIFY_E2E_FILE_CHAT_APP_ID', primaryWsId],
|
||||
// reasoning-chat.yml runs a real LLM node, so it is opt-in: provisioning it
|
||||
// requires the workspace to have a default chat model configured. Off by
|
||||
// default to keep the shared bootstrap free of any model dependency.
|
||||
...(process.env.DIFY_E2E_REASONING_PROVISION === '1'
|
||||
? [['reasoning-chat.yml', 'DIFY_E2E_REASONING_APP_ID', primaryWsId] as [string, string, string]]
|
||||
: []),
|
||||
...(edition === 'ee'
|
||||
? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]]
|
||||
: []),
|
||||
|
||||
91
cli/test/e2e/suites/run/run-app-reasoning.e2e.ts
Normal file
91
cli/test/e2e/suites/run/run-app-reasoning.e2e.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* E2E: difyctl run app — separated-mode reasoning (PR #37460)
|
||||
*
|
||||
* Exercises the out-of-band `reasoning_chunk` SSE channel against a real server.
|
||||
* Requires a chatflow whose LLM node uses reasoning_format=separated AND a
|
||||
* workspace with a configured chat model. The whole suite is skipped unless
|
||||
* DIFY_E2E_REASONING_APP_ID resolves (set it directly, or provision the
|
||||
* reasoning-chat.yml fixture with DIFY_E2E_REASONING_PROVISION=1).
|
||||
*
|
||||
* Verifies the client adaptation:
|
||||
* - --think surfaces the separated reasoning to stderr, framed as <think>…</think>
|
||||
* - the answer (stdout) stays free of <think>
|
||||
* - -o json persists the reasoning under metadata.reasoning
|
||||
* - without --think, reasoning stays hidden
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, inject } from 'vitest'
|
||||
import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js'
|
||||
import { registerConversation } from '../../helpers/cleanup-registry.js'
|
||||
import { withAuthFixture } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
// Skipped unless a separated-reasoning chatflow is wired up (needs a real model).
|
||||
const reasoningIt = optionalIt(Boolean(E.reasoningAppId))
|
||||
|
||||
const QUERY = 'In one short sentence, why is the sky blue?'
|
||||
|
||||
describe('E2E / difyctl run app — separated reasoning', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
reasoningIt('[P1] --think --stream surfaces reasoning on stderr, clean answer on stdout', async () => {
|
||||
const result = await withRetry(
|
||||
() => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '--stream']),
|
||||
{ attempts: 3, delayMs: 1000 },
|
||||
)
|
||||
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
// Separated mode keeps the answer free of <think>; reasoning is framed on stderr.
|
||||
expect(result.stdout).not.toContain('<think>')
|
||||
assertStderrContains(result, '<think>')
|
||||
})
|
||||
|
||||
reasoningIt('[P1] --think -o json persists reasoning under metadata.reasoning', async () => {
|
||||
const result = await withRetry(
|
||||
() => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 1000 },
|
||||
)
|
||||
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{
|
||||
conversation_id?: string
|
||||
answer: string
|
||||
metadata?: { reasoning?: Record<string, string> }
|
||||
}>(result)
|
||||
|
||||
if (parsed.conversation_id)
|
||||
registerConversation(E.host, E.token, E.reasoningAppId, parsed.conversation_id)
|
||||
|
||||
const reasoning = parsed.metadata?.reasoning ?? {}
|
||||
expect(Object.keys(reasoning).length).toBeGreaterThan(0)
|
||||
expect(Object.values(reasoning).join('').length).toBeGreaterThan(0)
|
||||
// --think also echoes the separated reasoning to stderr.
|
||||
assertStderrContains(result, '<think>')
|
||||
})
|
||||
|
||||
reasoningIt('[P1] without --think, reasoning stays hidden', async () => {
|
||||
const result = await withRetry(
|
||||
() => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--stream']),
|
||||
{ attempts: 3, delayMs: 1000 },
|
||||
)
|
||||
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
expect(result.stderr).not.toContain('<think>')
|
||||
})
|
||||
})
|
||||
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -15,6 +15,7 @@ export type Scenario
|
||||
| 'server-version-unsupported'
|
||||
| 'run-422-stale'
|
||||
| 'workflow-think'
|
||||
| 'chat-reasoning'
|
||||
| 'import-pending'
|
||||
| 'import-failed'
|
||||
|
||||
|
||||
12
cli/test/fixtures/dify-mock/server.ts
vendored
12
cli/test/fixtures/dify-mock/server.ts
vendored
@ -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' } })
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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" />
|
||||
|
||||
@ -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' }))
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}))
|
||||
@ -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'))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
|
||||
@ -40,6 +40,7 @@ const createHandlers = () => ({
|
||||
handleWorkflowAgentLog: vi.fn(),
|
||||
handleWorkflowTextChunk: vi.fn(),
|
||||
handleWorkflowTextReplace: vi.fn(),
|
||||
handleWorkflowReasoning: vi.fn(),
|
||||
handleWorkflowPaused: vi.fn(),
|
||||
})
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ type WorkflowRunEventHandlers = {
|
||||
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
|
||||
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
|
||||
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
|
||||
handleWorkflowReasoning: NonNullable<IOtherOptions['onReasoning']>
|
||||
handleWorkflowPaused: () => void
|
||||
}
|
||||
|
||||
@ -114,6 +115,7 @@ export const createBaseWorkflowRunCallbacks = ({
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowReasoning,
|
||||
handleWorkflowPaused,
|
||||
} = handlers
|
||||
const {
|
||||
@ -244,6 +246,9 @@ export const createBaseWorkflowRunCallbacks = ({
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onReasoning: (params) => {
|
||||
handleWorkflowReasoning(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
@ -325,6 +330,7 @@ export const createFinalWorkflowRunCallbacks = ({
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowReasoning,
|
||||
handleWorkflowPaused,
|
||||
} = handlers
|
||||
const {
|
||||
@ -439,6 +445,9 @@ export const createFinalWorkflowRunCallbacks = ({
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onReasoning: (params) => {
|
||||
handleWorkflowReasoning(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
|
||||
@ -78,6 +78,8 @@ export const createRunningWorkflowState = () => {
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
reasoningContent: {},
|
||||
reasoningFinished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -138,6 +138,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => {
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowReasoning,
|
||||
handleWorkflowPaused,
|
||||
} = useWorkflowRunEvent()
|
||||
|
||||
@ -326,6 +327,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => {
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowReasoning,
|
||||
handleWorkflowPaused,
|
||||
}
|
||||
const userCallbacks = {
|
||||
@ -443,7 +445,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => {
|
||||
},
|
||||
finalCallbacks,
|
||||
)
|
||||
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout])
|
||||
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowReasoning, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout])
|
||||
|
||||
const handleStopRun = useCallback((taskId: string) => {
|
||||
const setStoppedState = () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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 والوصف والدور لهذا الوكيل.",
|
||||
|
||||
@ -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": "عرض فقط"
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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 این عامل را بهروزرسانی کنید.",
|
||||
|
||||
@ -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": "فقط مشاهده کنید"
|
||||
}
|
||||
|
||||
@ -353,9 +353,6 @@
|
||||
"roster.deleteDialog.title": "Supprimer {{name}} ?",
|
||||
"roster.deleteFailed": "Échec de la suppression de l’agent.",
|
||||
"roster.deleteSuccess": "Agent supprimé.",
|
||||
"roster.duplicateDialog.description": "Créez une copie de {{name}} et personnalisez son identité dans le roster.",
|
||||
"roster.duplicateDialog.title": "Dupliquer l’agent",
|
||||
"roster.duplicateForm.changeIcon": "Changer l’icô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.",
|
||||
|
||||
@ -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 l’importation peut entraîner un dysfonctionnement de l’extrait.",
|
||||
"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
Reference in New Issue
Block a user