Merge remote-tracking branch 'upstream/main' into feat/upgrade-graphon

This commit is contained in:
yunlu.wen 2026-04-30 18:56:49 +08:00
commit 67e85b34e3
82 changed files with 4494 additions and 955 deletions

View File

@ -151,6 +151,12 @@ def deserialize_response(raw_data: bytes) -> Response:
response = Response(response=body, status=status_code)
# Replace Flask's default headers (e.g. Content-Type, Content-Length) with the
# parsed ones so we faithfully reproduce the original response. Use Headers.add
# rather than dict-style assignment so that repeated headers such as Set-Cookie
# (and any other multi-valued header per RFC 9110) are preserved instead of
# being overwritten.
response.headers.clear()
for line in lines[1:]:
if not line:
continue
@ -158,6 +164,6 @@ def deserialize_response(raw_data: bytes) -> Response:
if ":" not in line_str:
continue
name, value = line_str.split(":", 1)
response.headers[name] = value.strip()
response.headers.add(name, value.strip())
return response

View File

@ -365,7 +365,8 @@ class DifyNodeFactory(NodeFactory):
(including pydantic ValidationError, which subclasses ValueError),
if node type is unknown, or if no implementation exists for the resolved version
"""
typed_node_config = NodeConfigDictAdapter.validate_python(adapt_node_config_for_graph(node_config))
adapted_node_config = adapt_node_config_for_graph(node_config)
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config)
node_id = typed_node_config["id"]
node_data = typed_node_config["data"]
node_class = self._resolve_node_class(node_type=node_data.type, node_version=str(node_data.version))
@ -373,6 +374,11 @@ class DifyNodeFactory(NodeFactory):
# Re-validate using the resolved node class so workflow-local node schemas
# stay explicit and constructors receive the concrete typed payload.
resolved_node_data = self._validate_resolved_node_data(node_class, node_data)
config_for_node_init: BaseNodeData | dict[str, Any]
if isinstance(resolved_node_data, BaseNodeData):
config_for_node_init = resolved_node_data.model_dump(mode="python", by_alias=True)
else:
config_for_node_init = resolved_node_data
node_type = node_data.type
node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = {
BuiltinNodeTypes.CODE: lambda: {
@ -442,7 +448,7 @@ class DifyNodeFactory(NodeFactory):
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
return node_class(
node_id=node_id,
data=resolved_node_data,
data=config_for_node_init,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
**node_init_kwargs,
@ -474,10 +480,7 @@ class DifyNodeFactory(NodeFactory):
include_retriever_attachment_loader: bool,
include_jinja2_template_renderer: bool,
) -> dict[str, object]:
validated_node_data = cast(
LLMCompatibleNodeData,
self._validate_resolved_node_data(node_class=node_class, node_data=node_data),
)
validated_node_data = cast(LLMCompatibleNodeData, node_data)
model_instance = self._build_model_instance_for_llm_node(validated_node_data)
node_init_kwargs: dict[str, object] = {
"credentials_provider": self._llm_credentials_provider,

View File

@ -323,6 +323,50 @@ class TestDeserializeResponse:
with pytest.raises(ValueError, match="Invalid status line"):
deserialize_response(raw_data)
def test_deserialize_response_preserves_duplicate_set_cookie_headers(self):
# Regression test for https://github.com/langgenius/dify/issues/35722
# Multiple Set-Cookie headers must be preserved per RFC 9110, not collapsed
# into a single value by dict-style assignment.
raw_data = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/plain\r\n"
b"Set-Cookie: session=abc; Path=/; HttpOnly\r\n"
b"Set-Cookie: tracking=xyz; Path=/; Secure\r\n"
b"\r\n"
b"ok"
)
response = deserialize_response(raw_data)
cookies = response.headers.getlist("Set-Cookie")
assert cookies == [
"session=abc; Path=/; HttpOnly",
"tracking=xyz; Path=/; Secure",
]
# Single-valued headers should still be readable normally.
assert response.headers.get("Content-Type") == "text/plain"
def test_deserialize_response_preserves_duplicate_generic_headers(self):
# Any header name (not just Set-Cookie) may legitimately repeat; verify the
# parser preserves all values rather than overwriting earlier ones.
raw_data = b"HTTP/1.1 200 OK\r\nX-Custom: first\r\nX-Custom: second\r\n\r\n"
response = deserialize_response(raw_data)
assert response.headers.getlist("X-Custom") == ["first", "second"]
def test_deserialize_response_does_not_inject_default_content_type(self):
# Flask's Response constructor adds a default Content-Type header. When the
# raw response has no Content-Type, the parsed response should not silently
# gain one from the framework default.
raw_data = b"HTTP/1.1 204 No Content\r\nX-Trace-Id: abc\r\n\r\n"
response = deserialize_response(raw_data)
header_names = [name for name, _ in response.headers.items()]
assert "Content-Type" not in header_names
assert response.headers.get("X-Trace-Id") == "abc"
def test_roundtrip_response(self):
# Test that serialize -> deserialize produces equivalent response
original_response = Response(

View File

@ -5,6 +5,7 @@ from graphon.graph_events import (
NodeRunStreamChunkEvent,
)
from .test_mock_config import MockConfigBuilder
from .test_table_runner import TableTestRunner
@ -44,3 +45,51 @@ def test_tool_in_chatflow():
assert stream_chunk_events[0].chunk == "hello, dify!", (
f"Expected chunk to be 'hello, dify!', but got {stream_chunk_events[0].chunk}"
)
def test_answer_can_render_llm_structured_output_in_chatflow():
runner = TableTestRunner()
fixture_data = runner.workflow_runner.load_fixture("basic_chatflow")
nodes = fixture_data["workflow"]["graph"]["nodes"]
answer_node = next(node for node in nodes if node["id"] == "answer")
answer_node["data"]["answer"] = "{{#llm.structured_output#}}"
mock_config = (
MockConfigBuilder()
.with_node_output(
"llm",
{
"text": "plain text",
"structured_output": {"type": "greeting"},
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
"finish_reason": "stop",
},
)
.build()
)
graph, graph_runtime_state = runner.workflow_runner.create_graph_from_fixture(
fixture_data=fixture_data,
query="hello",
use_mock_factory=True,
mock_config=mock_config,
)
engine = GraphEngine(
workflow_id="test_workflow",
graph=graph,
graph_runtime_state=graph_runtime_state,
command_channel=InMemoryChannel(),
config=GraphEngineConfig(),
)
events = list(engine.run())
success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)]
assert success_events, "Workflow should complete successfully"
assert success_events[-1].outputs["answer"] == '{\n "type": "greeting"\n}'

View File

@ -86,3 +86,80 @@ def test_execute_answer():
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["answer"] == "Today's weather is sunny\nYou are a helpful AI.\n{{img}}\nFin."
def test_execute_answer_renders_structured_output_object_as_json() -> None:
init_params = build_test_graph_init_params(
workflow_id="1",
graph_config={"nodes": [], "edges": []},
tenant_id="1",
app_id="1",
user_id="1",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=0,
)
variable_pool = VariablePool(
system_variables=build_system_variables(user_id="aaa", files=[]),
user_inputs={},
environment_variables=[],
conversation_variables=[],
)
variable_pool.add(["1777539038857", "structured_output"], {"type": "greeting"})
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
node = AnswerNode(
node_id=str(uuid.uuid4()),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
config=AnswerNodeData(
title="123",
type="answer",
answer="{{#1777539038857.structured_output#}}",
),
)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["answer"] == '{\n "type": "greeting"\n}'
def test_execute_answer_falls_back_to_plain_selector_text_when_structured_output_missing() -> None:
init_params = build_test_graph_init_params(
workflow_id="1",
graph_config={"nodes": [], "edges": []},
tenant_id="1",
app_id="1",
user_id="1",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=0,
)
variable_pool = VariablePool(
system_variables=build_system_variables(user_id="aaa", files=[]),
user_inputs={},
environment_variables=[],
conversation_variables=[],
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
node = AnswerNode(
node_id=str(uuid.uuid4()),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
config=AnswerNodeData(
title="123",
type="answer",
answer="{{#1777539038857.structured_output#}}",
),
)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["answer"] == "1777539038857.structured_output"

View File

@ -10,14 +10,20 @@ from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.nodes.code.entities import CodeLanguage
from graphon.nodes.llm.entities import LLMNodeData
from graphon.variables.segments import StringSegment
def _assert_typed_node_config(config, *, node_id: str, node_type: NodeType, version: str = "1") -> None:
_ = node_id
assert isinstance(config, BaseNodeData)
assert config.type == node_type
assert config.version == version
if isinstance(config, BaseNodeData):
assert config.type == node_type
assert config.version == version
return
assert isinstance(config, dict)
assert config["type"] == node_type
assert config["version"] == version
def _node_constructor(*, return_value):
@ -546,6 +552,84 @@ class TestDifyNodeFactoryCreateNode:
assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config
assert kwargs["http_client"] is sentinel.http_client
def test_build_llm_compatible_node_init_kwargs_preserves_structured_output_switch(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
"structured_output_enabled": True,
"structured_output": {
"schema": {
"type": "object",
"properties": {"type": {"type": "string"}},
"required": ["type"],
}
},
}
)
wrapped_model_instance = sentinel.wrapped_model_instance
memory = sentinel.memory
factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance)
factory._build_memory_for_llm_node = MagicMock(return_value=memory)
with patch.object(node_factory, "DifyPreparedLLM", return_value=wrapped_model_instance) as prepared_llm:
kwargs = factory._build_llm_compatible_node_init_kwargs(
node_class=sentinel.node_class,
node_data=node_data,
wrap_model_instance=True,
include_http_client=True,
include_llm_file_saver=True,
include_prompt_message_serializer=True,
include_retriever_attachment_loader=True,
include_jinja2_template_renderer=True,
)
assert node_data.structured_output_switch_on is True
assert node_data.structured_output_enabled is True
factory._build_model_instance_for_llm_node.assert_called_once_with(node_data)
factory._build_memory_for_llm_node.assert_called_once_with(
node_data=node_data,
model_instance=sentinel.model_instance,
)
prepared_llm.assert_called_once_with(sentinel.model_instance)
assert kwargs["model_instance"] is wrapped_model_instance
def test_create_node_passes_alias_preserving_llm_config_to_constructor(self, monkeypatch, factory):
created_node = object()
constructor = _node_constructor(return_value=created_node)
monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=constructor))
monkeypatch.setattr(factory, "_build_llm_compatible_node_init_kwargs", MagicMock(return_value={}))
node_config = {
"id": "llm-node-id",
"data": {
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
"structured_output_enabled": True,
"structured_output": {
"schema": {
"type": "object",
"properties": {"type": {"type": "string"}},
"required": ["type"],
}
},
},
}
factory.create_node(node_config)
config = constructor.call_args.kwargs["config"]
assert isinstance(config, dict)
assert config["structured_output_enabled"] is True
assert "structured_output_switch_on" not in config
@pytest.mark.parametrize(
("node_type", "constructor_name", "expected_extra_kwargs"),
[

View File

@ -1744,11 +1744,6 @@
"count": 4
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": {
"erasable-syntax-only/parameter-properties": {
"count": 1
@ -3769,11 +3764,6 @@
"count": 1
}
},
"web/app/components/workflow/header/version-history-button.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/hooks-store/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -3796,7 +3786,7 @@
},
"web/app/components/workflow/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 27
"count": 26
}
},
"web/app/components/workflow/hooks/use-checklist.ts": {
@ -4994,11 +4984,6 @@
"count": 1
}
},
"web/app/components/workflow/operator/tip-popup.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/operator/zoom-in-out.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -5338,11 +5323,6 @@
"count": 5
}
},
"web/app/components/workflow/workflow-history-store.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"web/app/components/workflow/workflow-preview/components/nodes/base.tsx": {
"no-restricted-imports": {
"count": 1

221
pnpm-lock.yaml generated
View File

@ -48,6 +48,9 @@ catalogs:
'@heroicons/react':
specifier: 2.2.0
version: 2.2.0
'@hey-api/openapi-ts':
specifier: 0.97.0
version: 0.97.0
'@hono/node-server':
specifier: 2.0.0
version: 2.0.0
@ -168,6 +171,9 @@ catalogs:
'@tanstack/react-form-devtools':
specifier: 0.2.22
version: 0.2.22
'@tanstack/react-hotkeys':
specifier: 0.10.0
version: 0.10.0
'@tanstack/react-query':
specifier: 5.100.6
version: 5.100.6
@ -881,6 +887,9 @@ importers:
'@tanstack/react-form':
specifier: 'catalog:'
version: 1.29.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-hotkeys':
specifier: 'catalog:'
version: 0.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.100.6(react@19.2.5)
@ -1125,6 +1134,9 @@ importers:
'@eslint-react/eslint-plugin':
specifier: 'catalog:'
version: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
'@hey-api/openapi-ts':
specifier: 'catalog:'
version: 0.97.0(magicast@0.5.2)(typescript@6.0.3)
'@hono/node-server':
specifier: 'catalog:'
version: 2.0.0(hono@4.12.15)
@ -2072,6 +2084,31 @@ packages:
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@hey-api/codegen-core@0.8.1':
resolution: {integrity: sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA==}
engines: {node: '>=22.13.0'}
'@hey-api/json-schema-ref-parser@1.4.1':
resolution: {integrity: sha512-DoPJGxVApDlktP1yYLjmOrF0YBEqb32ieCbx1S1i09n8TyCgdoh4yQaQ3kp0sMTauH+bwNKPsFh7S8qiWCoKZA==}
engines: {node: '>=22.13.0'}
'@hey-api/openapi-ts@0.97.0':
resolution: {integrity: sha512-WZkKgrDlZpxKlDv2HkBCzaAYeuM+EtZKFmKGBv9/JblAKpX3JQTROi7PzlCZE3eisetRPSakbcRgn+LGyB7EiQ==}
engines: {node: '>=22.13.0'}
hasBin: true
peerDependencies:
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
'@hey-api/shared@0.4.2':
resolution: {integrity: sha512-4fconS10E0Xr4/acV8G+BkApxaIStxrT0GhB9BDTQWvrFTy5/nV933SyFk8qImcbpKvgv9hpn3N+7bV8oFrbjA==}
engines: {node: '>=22.13.0'}
'@hey-api/spec-types@0.2.0':
resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==}
'@hey-api/types@0.1.4':
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
'@hono/node-server@2.0.0':
resolution: {integrity: sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ==}
engines: {node: '>=20'}
@ -2294,6 +2331,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@lexical/clipboard@0.44.0':
resolution: {integrity: sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==}
@ -2369,6 +2409,10 @@ packages:
peerDependencies:
yjs: '>=13.5.22'
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@mdx-js/loader@3.1.1':
resolution: {integrity: sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==}
peerDependencies:
@ -3801,6 +3845,10 @@ packages:
peerDependencies:
solid-js: 1.9.11
'@tanstack/hotkeys@0.8.0':
resolution: {integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==}
engines: {node: '>=18'}
'@tanstack/pacer-lite@0.1.1':
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
engines: {node: '>=18'}
@ -3834,6 +3882,13 @@ packages:
'@tanstack/react-start':
optional: true
'@tanstack/react-hotkeys@0.10.0':
resolution: {integrity: sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-query-devtools@5.100.6':
resolution: {integrity: sha512-sz3ksMKA2t1rx0+Odzb0x1A3pXH/SVf7fzlzd3sKXzwXz8980f5sbOwfQD6+UfTG8G4Y2KaIg9e3sBn+uC4VTg==}
peerDependencies:
@ -3845,6 +3900,12 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-store@0.11.0':
resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-store@0.9.3':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
@ -3857,6 +3918,9 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/store@0.11.0':
resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==}
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
@ -4653,6 +4717,10 @@ packages:
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@4.1.1:
resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
engines: {node: '>=6'}
@ -4792,6 +4860,14 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
magicast: '*'
peerDependenciesMeta:
magicast:
optional: true
cac@7.0.0:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
@ -4858,6 +4934,10 @@ packages:
chevrotain@11.1.2:
resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@ -4931,6 +5011,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@ -5250,6 +5334,9 @@ packages:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
@ -5257,6 +5344,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -5309,6 +5399,10 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
@ -5893,6 +5987,10 @@ packages:
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
giget@3.2.0:
resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==}
hasBin: true
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@ -7137,6 +7235,9 @@ packages:
resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==}
engines: {node: '>=14.18.0'}
rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@ -7310,6 +7411,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@ -9332,6 +9437,56 @@ snapshots:
dependencies:
react: 19.2.5
'@hey-api/codegen-core@0.8.1(magicast@0.5.2)':
dependencies:
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
c12: 3.3.4(magicast@0.5.2)
color-support: 1.1.3
transitivePeerDependencies:
- magicast
'@hey-api/json-schema-ref-parser@1.4.1':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
yaml: 2.8.3
'@hey-api/openapi-ts@0.97.0(magicast@0.5.2)(typescript@6.0.3)':
dependencies:
'@hey-api/codegen-core': 0.8.1(magicast@0.5.2)
'@hey-api/json-schema-ref-parser': 1.4.1
'@hey-api/shared': 0.4.2(magicast@0.5.2)
'@hey-api/spec-types': 0.2.0
'@hey-api/types': 0.1.4
'@lukeed/ms': 2.0.2
ansi-colors: 4.1.3
color-support: 1.1.3
commander: 14.0.3
get-tsconfig: 4.14.0
typescript: 6.0.3
transitivePeerDependencies:
- magicast
'@hey-api/shared@0.4.2(magicast@0.5.2)':
dependencies:
'@hey-api/codegen-core': 0.8.1(magicast@0.5.2)
'@hey-api/json-schema-ref-parser': 1.4.1
'@hey-api/spec-types': 0.2.0
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
cross-spawn: 7.0.6
open: 11.0.0
semver: 7.7.4
transitivePeerDependencies:
- magicast
'@hey-api/spec-types@0.2.0':
dependencies:
'@hey-api/types': 0.1.4
'@hey-api/types@0.1.4': {}
'@hono/node-server@2.0.0(hono@4.12.15)':
dependencies:
hono: 4.12.15
@ -9517,6 +9672,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsdevtools/ono@7.1.3': {}
'@lexical/clipboard@0.44.0':
dependencies:
'@lexical/extension': 0.44.0
@ -9669,6 +9826,8 @@ snapshots:
'@lexical/selection': 0.44.0
lexical: 0.44.0
'@lukeed/ms@2.0.2': {}
'@mdx-js/loader@3.1.1':
dependencies:
'@mdx-js/mdx': 3.1.1
@ -10890,6 +11049,10 @@ snapshots:
- react
- vue
'@tanstack/hotkeys@0.8.0':
dependencies:
'@tanstack/store': 0.11.0
'@tanstack/pacer-lite@0.1.1': {}
'@tanstack/query-core@5.100.6': {}
@ -10928,6 +11091,13 @@ snapshots:
transitivePeerDependencies:
- react-dom
'@tanstack/react-hotkeys@0.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/hotkeys': 0.8.0
'@tanstack/react-store': 0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@tanstack/react-query-devtools@5.100.6(@tanstack/react-query@5.100.6(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/query-devtools': 5.100.6
@ -10939,6 +11109,13 @@ snapshots:
'@tanstack/query-core': 5.100.6
react: 19.2.5
'@tanstack/react-store@0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/store': 0.11.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
use-sync-external-store: 1.6.0(react@19.2.5)
'@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/store': 0.9.3
@ -10952,6 +11129,8 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@tanstack/store@0.11.0': {}
'@tanstack/store@0.9.3': {}
'@tanstack/virtual-core@3.14.0': {}
@ -11863,6 +12042,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-colors@4.1.3: {}
ansi-regex@4.1.1: {}
ansi-regex@5.0.1: {}
@ -11977,6 +12158,23 @@ snapshots:
bytes@3.1.2: {}
c12@3.3.4(magicast@0.5.2):
dependencies:
chokidar: 5.0.0
confbox: 0.2.4
defu: 6.1.7
dotenv: 17.4.2
exsolve: 1.0.8
giget: 3.2.0
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
pkg-types: 2.3.0
rc9: 3.0.1
optionalDependencies:
magicast: 0.5.2
cac@7.0.0: {}
camelize@1.0.1: {}
@ -12064,6 +12262,10 @@ snapshots:
'@chevrotain/utils': 11.1.2
lodash-es: 4.18.0
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@1.1.4:
optional: true
@ -12135,6 +12337,8 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
comma-separated-tokens@2.0.3: {}
commander@14.0.0: {}
@ -12457,12 +12661,16 @@ snapshots:
define-lazy-prop@3.0.0: {}
defu@6.1.7: {}
delaunator@5.1.0:
dependencies:
robust-predicates: 3.0.3
dequal@2.0.3: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@ -12511,6 +12719,8 @@ snapshots:
dotenv@16.6.1: {}
dotenv@17.4.2: {}
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.5):
dependencies:
echarts: 6.0.0
@ -13274,6 +13484,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
giget@3.2.0: {}
github-from-package@0.0.0:
optional: true
@ -14928,6 +15140,11 @@ snapshots:
radash@12.1.1: {}
rc9@3.0.1:
dependencies:
defu: 6.1.7
destr: 2.0.5
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
@ -15126,6 +15343,8 @@ snapshots:
util-deprecate: 1.0.2
optional: true
readdirp@5.0.0: {}
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@ -16330,6 +16549,7 @@ snapshots:
time:
'@amplitude/analytics-browser@2.42.0': '2026-04-28T17:01:08.442Z'
'@amplitude/plugin-session-replay-browser@1.28.1': '2026-04-28T17:01:37.145Z'
'@hey-api/openapi-ts@0.97.0': '2026-04-28T03:33:22.380Z'
'@hono/node-server@2.0.0': '2026-04-21T00:25:40.852Z'
'@lexical/link@0.44.0': '2026-04-27T14:47:45.477Z'
'@lexical/list@0.44.0': '2026-04-27T14:47:48.463Z'
@ -16338,6 +16558,7 @@ time:
'@lexical/text@0.44.0': '2026-04-27T14:48:23.958Z'
'@lexical/utils@0.44.0': '2026-04-27T14:48:26.689Z'
'@tanstack/eslint-plugin-query@5.100.6': '2026-04-28T16:39:45.129Z'
'@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z'
'@tanstack/react-query-devtools@5.100.6': '2026-04-28T16:39:51.334Z'
'@tanstack/react-query@5.100.6': '2026-04-28T16:39:52.105Z'
'@tsslint/cli@3.1.0': '2026-04-29T04:57:38.423Z'

View File

@ -68,6 +68,7 @@ catalog:
'@formatjs/intl-localematcher': 0.8.4
'@headlessui/react': 2.2.10
'@heroicons/react': 2.2.0
'@hey-api/openapi-ts': 0.97.0
'@hono/node-server': 2.0.0
'@iconify-json/heroicons': 1.2.3
'@iconify-json/ri': 1.2.10
@ -109,6 +110,7 @@ catalog:
'@tanstack/react-devtools': 0.10.2
'@tanstack/react-form': 1.29.1
'@tanstack/react-form-devtools': 0.2.22
'@tanstack/react-hotkeys': 0.10.0
'@tanstack/react-query': 5.100.6
'@tanstack/react-query-devtools': 5.100.6
'@tanstack/react-virtual': 3.13.24

View File

@ -4,4 +4,8 @@ export default defineConfig({
staged: {
'*': 'eslint --fix --pass-on-unpruned-suppressions',
},
fmt: {
singleQuote: true,
semi: false,
},
})

View File

@ -1,5 +1,6 @@
import type { CSSProperties, FC } from 'react'
import type { ModelAndParameter } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -92,11 +93,16 @@ const DebugItem: FC<DebugItemProps> = ({
modelAndParameter={modelAndParameter}
/>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<div />}>
<ActionButton className={open ? 'bg-state-base-hover' : ''}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<ActionButton
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -92,12 +92,12 @@ const WorkflowProcessItem = ({
)
}
<div
className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}
className="min-w-0 grow truncate system-xs-medium text-text-secondary"
data-testid="workflow-process-title"
>
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
</div>
<div className={cn('ml-1 i-ri-arrow-right-s-line h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
<div className={cn('ml-1 i-ri-arrow-right-s-line h-4 w-4 shrink-0 text-text-tertiary', !collapse && 'rotate-90')} />
</div>
{
!collapse && (

View File

@ -29,6 +29,7 @@ import {
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -928,5 +929,46 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
vi.useRealTimers()
})
it('does not hide the menu when focus moves into a variable child popup', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="/"
workflowVariableBlock={makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child', VarType.string)]),
]),
])}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '/', true)
expect(await screen.findByText('payload')).toBeInTheDocument()
vi.useFakeTimers()
const popupTarget = document.createElement('button')
const popup = document.createElement('div')
popup.classList.add(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME)
popup.appendChild(popupTarget)
document.body.appendChild(popup)
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('payload')).toBeInTheDocument()
popup.remove()
vi.useRealTimers()
})
})
})

View File

@ -14,6 +14,7 @@ import type {
WorkflowVariableBlockType,
} from '../../types'
import type { PickerBlockMenuOption } from './menu'
import type { EventEmitterValue } from '@/context/event-emitter'
import {
flip,
offset,
@ -39,7 +40,7 @@ import {
} from 'react'
import ReactDOM from 'react-dom'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
import { $splitNodeContainingQuery } from '../../utils'
@ -119,7 +120,9 @@ const ComponentPicker = ({
(event) => {
clearBlurTimer()
const target = event?.relatedTarget as HTMLElement
if (!target?.classList?.contains('var-search-input'))
const isVariableMenuTarget = target?.classList?.contains('var-search-input')
|| target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`)
if (!isVariableMenuTarget)
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
return false
},
@ -143,8 +146,8 @@ const ComponentPicker = ({
}
}, [editor, clearBlurTimer])
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
eventEmitter?.useSubscription((v: EventEmitterValue) => {
if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string')
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
})
@ -303,7 +306,7 @@ const ComponentPicker = ({
}
</>
)
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
return (
<LexicalTypeaheadMenuPlugin

View File

@ -363,9 +363,7 @@ describe('Breadcrumbs', () => {
render(<Breadcrumbs {...props} />)
// Assert - Dropdown trigger (more button) should be present
// Assert - Dropdown trigger (more button) should be present
expect(screen.getByRole('button', { name: '' }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.more' }))!.toBeInTheDocument()
})
it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => {

View File

@ -6,6 +6,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Menu from './menu'
type DropdownProps = {
@ -19,6 +20,7 @@ const Dropdown = ({
breadcrumbs,
onBreadcrumbClick,
}: DropdownProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleBreadCrumbClick = useCallback((index: number) => {
@ -31,17 +33,21 @@ const Dropdown = ({
open={open}
onOpenChange={setOpen}
>
<DropdownMenuTrigger render={<div />}>
<button
type="button"
className={cn(
'flex size-6 items-center justify-center rounded-md',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex size-6 items-center justify-center rounded-md',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</button>
)}
/>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}

View File

@ -1,5 +1,6 @@
import { subscribeWorkflowCommand, WorkflowCommand } from '@/app/components/workflow/shortcuts/commands'
import { registerCommands, unregisterCommands } from '../command-bus'
import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
import { zenCommand } from '../zen'
vi.mock('../command-bus')
@ -24,10 +25,6 @@ describe('zenCommand', () => {
expect(zenCommand.execute).toBeDefined()
})
it('exports ZEN_TOGGLE_EVENT constant', () => {
expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
})
describe('isAvailable', () => {
it('delegates to isInWorkflowPage', async () => {
const { isInWorkflowPage } = vi.mocked(
@ -43,15 +40,14 @@ describe('zenCommand', () => {
})
describe('execute', () => {
it('dispatches custom zen-toggle event', () => {
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
it('emits the workflow canvas maximize command', () => {
const listener = vi.fn()
const unsubscribe = subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, listener)
zenCommand.execute?.()
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
)
dispatchSpy.mockRestore()
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
})

View File

@ -3,17 +3,17 @@ import { RiFullscreenLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { isInWorkflowPage } from '@/app/components/workflow/constants'
import {
emitWorkflowCommand,
WorkflowCommand,
} from '@/app/components/workflow/shortcuts/commands'
import { registerCommands, unregisterCommands } from './command-bus'
// Zen command dependency types - no external dependencies needed
type ZenDeps = Record<string, never>
// Custom event name for zen toggle
export const ZEN_TOGGLE_EVENT = 'zen-toggle-maximize'
// Shared function to dispatch zen toggle event
const toggleZenMode = () => {
window.dispatchEvent(new CustomEvent(ZEN_TOGGLE_EVENT))
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
}
/**

View File

@ -1,6 +1,7 @@
import type {
DataSourceCredential,
} from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -45,11 +46,17 @@ const Operator = ({
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<div />}>
<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]">
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault')}>
<span aria-hidden className="i-ri-home-9-line h-4 w-4 text-text-tertiary" />

View File

@ -218,7 +218,6 @@ describe('EdgeContextmenu', () => {
})
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
expect(screen.getByText(/^del$/i))!.toBeInTheDocument()
await user.click(deleteAction)

View File

@ -1,6 +1,7 @@
import type { Edge, Node } from '../types'
import { render, screen } from '@testing-library/react'
import { useStoreApi } from 'reactflow'
import { WorkflowContextProvider } from '../context'
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
import WorkflowWithDefaultContext from '../index'
import { BlockEnum } from '../types'
@ -35,14 +36,13 @@ const edges: Edge[] = [
]
const ContextConsumer = () => {
const { store, shortcutsEnabled } = useWorkflowHistoryStore()
const { store } = useWorkflowHistoryStore()
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
const reactFlowStore = useStoreApi()
return (
<div>
{`history:${store.getState().nodes.length}`}
{` shortcuts:${String(shortcutsEnabled)}`}
{` datasets:${datasetCount}`}
{` reactflow:${String(!!reactFlowStore)}`}
</div>
@ -52,16 +52,18 @@ const ContextConsumer = () => {
describe('WorkflowWithDefaultContext', () => {
it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
render(
<WorkflowWithDefaultContext
nodes={nodes}
edges={edges}
>
<ContextConsumer />
</WorkflowWithDefaultContext>,
<WorkflowContextProvider>
<WorkflowWithDefaultContext
nodes={nodes}
edges={edges}
>
<ContextConsumer />
</WorkflowWithDefaultContext>
</WorkflowContextProvider>,
)
expect(
screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
screen.getByText('history:1 datasets:0 reactflow:true'),
).toBeInTheDocument()
})
})

View File

@ -145,8 +145,8 @@ describe('PanelContextmenu', () => {
const { container } = render(<PanelContextmenu />)
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r')
expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v')
expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/)
expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/)
expect(container.firstChild).toHaveStyle({
left: '24px',
top: '48px',

View File

@ -251,17 +251,23 @@ vi.mock('../hooks/use-workflow-comment', () => ({
vi.mock('../base/confirm', () => ({
default: ({
isShow,
title,
desc,
onConfirm,
onCancel,
}: {
isShow: boolean
title?: string
desc?: string
onConfirm: () => void
onCancel: () => void
}) => isShow
? (
<div data-testid="confirm-dialog">
<button type="button" onClick={onConfirm}>confirm</button>
<button type="button" onClick={onCancel}>cancel</button>
<div role="alertdialog" data-testid="confirm-dialog">
{title && <div>{title}</div>}
{desc && <div>{desc}</div>}
<button type="button" onClick={onConfirm}>common.operation.confirm</button>
<button type="button" onClick={onCancel}>common.operation.cancel</button>
</div>
)
: null,
@ -338,6 +344,11 @@ vi.mock('../syncing-data-modal', () => ({
default: () => null,
}))
vi.mock('../shortcuts/use-workflow-hotkeys', () => ({
useWorkflowHotkeys: workflowHookMocks.useShortcuts,
useWorkflowShortcut: vi.fn(),
}))
vi.mock('../hooks', () => ({
useEdgesInteractions: () => ({
handleEdgeEnter: workflowHookMocks.handleEdgeEnter,

View File

@ -1,9 +1,10 @@
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
import type { Edge, Node } from '../types'
import type { WorkflowHistoryState } from '../workflow-history-store'
import { render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderHook } from '@testing-library/react'
import { WorkflowContext } from '../context'
import { createWorkflowStore } from '../store/workflow'
import { BlockEnum } from '../types'
import { useWorkflowHistoryStore, WorkflowHistoryProvider } from '../workflow-history-store'
import { useWorkflowHistoryStore } from '../workflow-history-store'
const nodes: Node[] = [
{
@ -36,44 +37,28 @@ const edges: Edge[] = [
},
]
const HistoryConsumer = () => {
const { store, shortcutsEnabled, setShortcutsEnabled } = useWorkflowHistoryStore()
const createWrapper = () => {
const workflowStore = createWorkflowStore({})
workflowStore.temporal.getState().pause()
workflowStore.getState().setWorkflowHistory({
nodes,
edges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
})
workflowStore.temporal.getState().clear()
workflowStore.temporal.getState().resume()
return (
<button onClick={() => setShortcutsEnabled(!shortcutsEnabled)}>
{`nodes:${store.getState().nodes.length} shortcuts:${String(shortcutsEnabled)}`}
</button>
return ({ children }: { children: React.ReactNode }) => (
<WorkflowContext.Provider value={workflowStore}>
{children}
</WorkflowContext.Provider>
)
}
describe('WorkflowHistoryProvider', () => {
it('provides workflow history state and shortcut toggles', async () => {
const user = userEvent.setup()
render(
<WorkflowHistoryProvider
nodes={nodes}
edges={edges}
>
<HistoryConsumer />
</WorkflowHistoryProvider>,
)
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))!.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:false' }))!.toBeInTheDocument()
})
describe('workflow history store', () => {
it('sanitizes selected flags when history state is replaced through the exposed store api', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<WorkflowHistoryProvider
nodes={nodes}
edges={edges}
>
{children}
</WorkflowHistoryProvider>
)
const wrapper = createWrapper()
const { result } = renderHook(() => useWorkflowHistoryStore(), { wrapper })
const nextState: WorkflowHistoryState = {
@ -91,7 +76,7 @@ describe('WorkflowHistoryProvider', () => {
it('throws when consumed outside the provider', () => {
expect(() => renderHook(() => useWorkflowHistoryStore())).toThrow(
'useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider',
'Missing WorkflowContext.Provider in the tree',
)
})
})

View File

@ -63,22 +63,18 @@
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
import type { Shape as HooksStoreShape } from '../hooks-store/store'
import type { Shape } from '../store/workflow'
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
import type { Edge, Node, WorkflowRunningData } from '../types'
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { temporal } from 'zundo'
import { create } from 'zustand'
import { seedSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { WorkflowContext } from '../context'
import { HooksStoreContext } from '../hooks-store/provider'
import { createHooksStore } from '../hooks-store/store'
import { createWorkflowStore } from '../store/workflow'
import { WorkflowRunningStatus } from '../types'
import { WorkflowHistoryStoreContext } from '../workflow-history-store'
// Re-exports are in a separate non-JSX file to avoid react-refresh warnings.
// Import directly from the individual modules:
@ -156,9 +152,13 @@ function createWorkflowWrapper(
historyConfig?: HistoryStoreConfig,
externalQueryClient?: QueryClient,
) {
const historyCtxValue = historyConfig
? createTestHistoryStoreContext(historyConfig)
: undefined
if (historyConfig) {
stores.store.temporal.getState().pause()
stores.store.getState().setWorkflowHistory(createTestWorkflowHistoryState(historyConfig))
stores.store.temporal.getState().clear()
stores.store.temporal.getState().resume()
}
const queryClient = externalQueryClient ?? new QueryClient({
defaultOptions: {
queries: {
@ -172,14 +172,6 @@ function createWorkflowWrapper(
return ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
if (historyCtxValue) {
inner = React.createElement(
WorkflowHistoryStoreContext.Provider,
{ value: historyCtxValue },
inner,
)
}
if (stores.hooksStore) {
inner = React.createElement(
HooksStoreContext.Provider,
@ -214,7 +206,7 @@ type WorkflowHookTestResult<R, P> = RenderHookResult<R, P> & StoreInstances
* Contexts provided based on options:
* - **Always**: `WorkflowContext` (real zustand store)
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
* - **historyStore**: workflow history zundo store on `WorkflowContext`
*/
export function renderWorkflowHook<R, P = undefined>(
hook: (props: P) => R,
@ -243,7 +235,7 @@ type WorkflowComponentTestResult = RenderResult & StoreInstances
* Provides the same context layers as `renderWorkflowHook`:
* - **Always**: `WorkflowContext` (real zustand store)
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
* - **historyStore**: workflow history zundo store on `WorkflowContext`
*/
export function renderWorkflowComponent(
ui: React.ReactElement,
@ -393,36 +385,13 @@ export function renderNodeComponent<T extends Record<string, unknown>>(
// WorkflowHistoryStore test helper
// ---------------------------------------------------------------------------
function createTestHistoryStoreContext(config: HistoryStoreConfig) {
function createTestWorkflowHistoryState(config: HistoryStoreConfig): WorkflowHistoryState {
const nodes = config.nodes ?? []
const edges = config.edges ?? []
type HistState = {
workflowHistoryEvent: string | undefined
workflowHistoryEventMeta: unknown
nodes: Node[]
edges: Edge[]
getNodes: () => Node[]
setNodes: (n: Node[]) => void
setEdges: (e: Edge[]) => void
}
const store = create(temporal<HistState>(
(set, get) => ({
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
nodes,
edges,
getNodes: () => get().nodes,
setNodes: (n: Node[]) => set({ nodes: n }),
setEdges: (e: Edge[]) => set({ edges: e }),
}),
{ equality: (a, b) => isDeepEqual(a, b) },
)) as unknown as WorkflowHistoryStoreApi
return {
store,
shortcutsEnabled: true,
setShortcutsEnabled: () => {},
nodes,
edges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
}
}

View File

@ -74,11 +74,16 @@ const OperationDropdown: FC<Props> = ({
open={open}
onOpenChange={setOpen}
>
<DropdownMenuTrigger render={<div />}>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<ActionButton
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
</ActionButton>
)}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -1,12 +1,12 @@
import type { StateCreator } from 'zustand'
import type { SliceFromInjection } from './store'
import type { SliceFromInjection } from './store/workflow'
import {
createContext,
useRef,
} from 'react'
import {
createWorkflowStore,
} from './store'
} from './store/workflow'
type WorkflowStore = ReturnType<typeof createWorkflowStore>
export const WorkflowContext = createContext<WorkflowStore | null>(null)

View File

@ -10,7 +10,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import { useEdgesInteractions, usePanelInteractions } from './hooks'
import ShortcutsName from './shortcuts-name'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
import { useStore } from './store'
const EdgeContextmenu = () => {
@ -53,7 +53,7 @@ const EdgeContextmenu = () => {
onClick={() => handleEdgeDeleteById(edgeMenu.edgeId)}
>
<span>{t('common:operation.delete')}</span>
<ShortcutsName keys={['del']} />
<ShortcutKbd shortcut="workflow.delete" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>

View File

@ -37,11 +37,15 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
vi.mock('@/app/components/workflow/store/workflow', () => ({
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
vi.mock('@/app/components/workflow/shortcuts/use-workflow-hotkeys', () => ({
useWorkflowShortcut: vi.fn(),
}))
vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))

View File

@ -27,12 +27,17 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
render,
}: {
children: React.ReactNode
render?: React.ReactElement
render?: React.ReactElement<{ children?: React.ReactNode }>
}) => {
const { open, setOpen } = useDropdownMenuContext()
if (render)
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
if (render) {
return React.cloneElement(
render,
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
children ?? render.props.children,
)
}
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
},

View File

@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import VersionHistoryButton from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light'
const workflowShortcutHandlers = vi.hoisted(() => new Map<string, () => void | Promise<void>>())
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
@ -9,17 +10,22 @@ vi.mock('@/hooks/use-theme', () => ({
}),
}))
vi.mock('../../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils')>()
return {
...actual,
getKeyboardKeyCodeBySystem: () => 'ctrl',
}
})
vi.mock('../../shortcuts/use-workflow-hotkeys', () => ({
useWorkflowShortcut: (id: string, callback: () => void | Promise<void>) => {
workflowShortcutHandlers.set(id, callback)
},
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('VersionHistoryButton', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowShortcutHandlers.clear()
mockTheme = 'light'
})
@ -32,22 +38,14 @@ describe('VersionHistoryButton', () => {
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should trigger onClick when the version history shortcut is pressed', () => {
it('should trigger onClick when the version history shortcut is pressed', async () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'H',
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
await act(async () => {
await workflowShortcutHandlers.get('workflow.version-history')?.()
})
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
window.dispatchEvent(keyboardEvent)
expect(keyboardEvent.defaultPrevented).toBe(true)
expect(onClick).toHaveBeenCalledTimes(1)
})

View File

@ -1,15 +1,15 @@
import type { TestRunMenuRef, TriggerOption } from './test-run-menu'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { useStore } from '@/app/components/workflow/store'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useWorkflowShortcut } from '@/app/components/workflow/shortcuts/use-workflow-hotkeys'
import { useStore } from '@/app/components/workflow/store/workflow'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -42,17 +42,12 @@ const RunMode = ({
const dynamicOptions = useDynamicTestRunOptions()
const testRunMenuRef = useRef<TestRunMenuRef>(null)
useEffect(() => {
// @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts
window._toggleTestRunDropdown = () => {
testRunMenuRef.current?.toggle()
}
return () => {
// @ts-expect-error - Dynamic property cleanup
delete window._toggleTestRunDropdown
}
const handleToggleTestRunMenu = useCallback(() => {
testRunMenuRef.current?.toggle()
}, [])
useWorkflowShortcut('workflow.open-test-run-menu', handleToggleTestRunMenu)
const handleStop = useCallback(() => {
handleStopRun(workflowRunningData?.task_id || '')
}, [handleStopRun, workflowRunningData?.task_id])
@ -117,7 +112,7 @@ const RunMode = ({
)}
disabled={true}
>
<RiLoader2Line className="mr-1 size-4 animate-spin" />
<span className="mr-1 i-ri-loader-2-line size-4 animate-spin" />
{isListening ? t('common.listening', { ns: 'workflow' }) : t('common.running', { ns: 'workflow' })}
</button>
)
@ -127,16 +122,17 @@ const RunMode = ({
options={dynamicOptions}
onSelect={handleTriggerSelect}
>
<div
<button
type="button"
className={cn(
'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent hover:bg-state-accent-hover',
)}
style={{ userSelect: 'none' }}
>
<RiPlayLargeLine className="mr-1 size-4" />
<span aria-hidden className="mr-1 i-ri-play-large-line size-4" />
{text ?? t('common.run', { ns: 'workflow' })}
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
</div>
<ShortcutKbd shortcut="workflow.open-test-run-menu" textColor="secondary" />
</button>
</TestRunMenu>
)
}

View File

@ -7,7 +7,7 @@ import {
isValidElement,
useEffect,
} from 'react'
import ShortcutsName from '../shortcuts-name'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
export type ShortcutMapping = {
option: TriggerOption
@ -39,7 +39,7 @@ export const OptionRow = ({
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
<ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />
)}
</DropdownMenuItem>
)
@ -111,8 +111,8 @@ export const SingleOptionTrigger = ({
}
return (
<span onClick={handleRunClick}>
<button type="button" onClick={handleRunClick}>
{children}
</span>
</button>
)
}

View File

@ -1,6 +1,6 @@
import type { ShortcutMapping } from './test-run-menu-helpers'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { forwardRef, isValidElement, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
@ -145,9 +145,18 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
open={open}
onOpenChange={setOpen}
>
<DropdownMenuTrigger render={<div style={{ userSelect: 'none' }} />}>
{children}
</DropdownMenuTrigger>
{isValidElement(children)
? (
<DropdownMenuTrigger
render={children}
style={{ userSelect: 'none' }}
/>
)
: (
<DropdownMenuTrigger style={{ userSelect: 'none' }}>
{children}
</DropdownMenuTrigger>
)}
<DropdownMenuContent
placement="bottom-start"
sideOffset={8}

View File

@ -28,7 +28,7 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
return (
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcut="workflow.undo">
<button
type="button"
aria-label={t('common.undo', { ns: 'workflow' })!}
@ -43,7 +43,7 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
<span className="i-ri-arrow-go-back-line h-4 w-4" />
</button>
</TipPopup>
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcut="workflow.redo">
<button
type="button"
aria-label={t('common.redo', { ns: 'workflow' })!}

View File

@ -1,22 +1,22 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiHistoryLine } from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import Tooltip from '../../base/tooltip'
import ShortcutsName from '../shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../utils'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
import { useWorkflowShortcut } from '../shortcuts/use-workflow-hotkeys'
type VersionHistoryButtonProps = {
onClick: () => Promise<unknown> | unknown
}
const VERSION_HISTORY_SHORTCUT = ['ctrl', '⇧', 'H']
const PopupContent = React.memo(() => {
const { t } = useTranslation()
return (
@ -24,7 +24,7 @@ const PopupContent = React.memo(() => {
<div className="px-0.5 system-xs-medium text-text-secondary">
{t('common.versionHistory', { ns: 'workflow' })}
</div>
<ShortcutsName keys={VERSION_HISTORY_SHORTCUT} bgColor="gray" textColor="secondary" />
<ShortcutKbd shortcut="workflow.version-history" bgColor="gray" textColor="secondary" />
</div>
)
})
@ -39,27 +39,30 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
await onClick?.()
}, [onClick])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => {
e.preventDefault()
useWorkflowShortcut('workflow.version-history', () => {
handleViewVersionHistory()
}, { exactMatch: true, useCapture: true })
})
return (
<Tooltip
popupContent={<PopupContent />}
noDecoration
popupClassName="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg
shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px] p-1.5"
>
<Button
className={cn(
'rounded-lg border border-transparent p-2',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
<Tooltip>
<TooltipTrigger
render={(
<Button
className={cn(
'rounded-lg border border-transparent p-2',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
)}
onClick={handleViewVersionHistory}
>
<span className="i-ri-history-line h-4 w-4 text-components-button-secondary-text" />
</Button>
)}
onClick={handleViewVersionHistory}
/>
<TooltipContent
className="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
>
<RiHistoryLine className="h-4 w-4 text-components-button-secondary-text" />
</Button>
<PopupContent />
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,7 +1,8 @@
import type { WorkflowHistoryState } from '../workflow-history-store'
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
@ -141,6 +142,7 @@ const ViewWorkflowHistory = () => {
return (
(
<Popover
modal="trap-focus"
open={open}
onOpenChange={(nextOpen) => {
if (nodesReadOnly)
@ -148,49 +150,56 @@ const ViewWorkflowHistory = () => {
setOpen(nextOpen)
}}
>
<TipPopup
title={t('changeHistory.title', { ns: 'workflow' })}
>
<PopoverTrigger
nativeButton={false}
render={(
<div
className={
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => {
if (nodesReadOnly)
return
setCurrentLogItem()
setShowMessageLogModal(false)
}}
<PopoverTrigger
render={(
<button
type="button"
aria-label={t('changeHistory.title', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={
cn('box-border inline-flex h-8 max-h-8 min-h-8 w-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => {
if (nodesReadOnly)
return
setCurrentLogItem()
setShowMessageLogModal(false)
}}
>
<TipPopup
title={t('changeHistory.title', { ns: 'workflow' })}
>
<RiHistoryLine className="h-4 w-4" />
</div>
)}
/>
</TipPopup>
<span className="flex h-full w-full shrink-0 items-center justify-center">
<span className="i-ri-history-line h-4 w-4 shrink-0" />
</span>
</TipPopup>
</button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={131}
popupClassName="border-none bg-transparent shadow-none"
>
<div
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
className="flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
>
<div className="sticky top-0 flex items-center justify-between px-4 pt-3">
<div className="system-mg-regular grow text-text-secondary">{t('changeHistory.title', { ns: 'workflow' })}</div>
<div
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
<PopoverClose
render={(
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
>
<RiCloseLine className="h-4 w-4 text-text-secondary" />
</button>
)}
onClick={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
setOpen(false)
}}
>
<RiCloseLine className="h-4 w-4 text-text-secondary" />
</div>
/>
</div>
<div
className="overflow-y-auto p-2"

View File

@ -1,13 +1,17 @@
import { act } from '@testing-library/react'
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useShortcuts } from '../use-shortcuts'
import { emitWorkflowCommand, WorkflowCommand } from '../../shortcuts/commands'
import { useWorkflowHotkeys } from '../../shortcuts/use-workflow-hotkeys'
type KeyPressRegistration = {
keyFilter: unknown
handler: (event: KeyboardEvent) => void
options?: {
events?: string[]
enabled?: boolean
ignoreInputs?: boolean
preventDefault?: boolean
stopPropagation?: boolean
}
}
@ -18,6 +22,12 @@ type ReactFlowNodeMock = {
}
}
type HotkeyDefinitionMock = {
hotkey: unknown
callback: (event: KeyboardEvent) => void
options?: KeyPressRegistration['options'] & { eventType?: 'keydown' | 'keyup' }
}
const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => [])
const mockZoomTo = vi.hoisted(() => vi.fn())
const mockGetZoom = vi.hoisted(() => vi.fn(() => 1))
@ -35,14 +45,34 @@ const mockUndimAllNodes = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockHandleModeHand = vi.hoisted(() => vi.fn())
const mockHandleModePointer = vi.hoisted(() => vi.fn())
const mockHandleModeComment = vi.hoisted(() => vi.fn())
const mockHandleLayout = vi.hoisted(() => vi.fn())
const mockHandleToggleMaximizeCanvas = vi.hoisted(() => vi.fn())
const mockUseKeyHold = vi.hoisted(() => vi.fn(() => false))
vi.mock('ahooks', () => ({
useKeyPress: (keyFilter: unknown, handler: (event: KeyboardEvent) => void, options?: { events?: string[] }) => {
keyPressRegistrations.push({ keyFilter, handler, options })
},
}))
vi.mock('@tanstack/react-hotkeys', () => {
const useHotkeys = (
definitions: HotkeyDefinitionMock[],
commonOptions?: KeyPressRegistration['options'],
) => {
definitions.forEach((definition) => {
keyPressRegistrations.push({
keyFilter: definition.hotkey,
handler: definition.callback,
options: {
...commonOptions,
...definition.options,
events: definition.options?.eventType ? [definition.options.eventType] : undefined,
},
})
})
}
return {
useHotkeys,
useKeyHold: mockUseKeyHold,
}
})
vi.mock('reactflow', () => ({
useReactFlow: () => ({
@ -53,7 +83,7 @@ vi.mock('reactflow', () => ({
}),
}))
vi.mock('..', () => ({
vi.mock('../use-nodes-interactions', () => ({
useNodesInteractions: () => ({
handleNodesCopy: mockHandleNodesCopy,
handleNodesPaste: mockHandleNodesPaste,
@ -64,32 +94,44 @@ vi.mock('..', () => ({
dimOtherNodes: mockDimOtherNodes,
undimAllNodes: mockUndimAllNodes,
}),
}))
vi.mock('../use-edges-interactions', () => ({
useEdgesInteractions: () => ({
handleEdgeDelete: mockHandleEdgeDelete,
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}))
vi.mock('../use-workflow-canvas-maximize', () => ({
useWorkflowCanvasMaximize: () => ({
handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
}),
}))
vi.mock('../use-workflow-panel-interactions', () => ({
useWorkflowMoveMode: () => ({
handleModeHand: mockHandleModeHand,
handleModePointer: mockHandleModePointer,
handleModeComment: mockHandleModeComment,
isCommentModeAvailable: true,
}),
}))
vi.mock('../use-workflow-organize', () => ({
useWorkflowOrganize: () => ({
handleLayout: mockHandleLayout,
}),
}))
vi.mock('../../workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
shortcutsEnabled: true,
}),
}))
const createKeyboardEvent = (target: HTMLElement = document.body) => ({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target,
}) as unknown as KeyboardEvent
@ -107,49 +149,73 @@ const findRegistration = (matcher: (registration: KeyPressRegistration) => boole
return registration as KeyPressRegistration
}
const isEditableTarget = (target: EventTarget | null) => {
return target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| (target instanceof HTMLElement && target.isContentEditable)
}
const triggerShortcut = (
registration: KeyPressRegistration,
event: KeyboardEvent = createKeyboardEvent(),
) => {
if (registration.options?.enabled === false)
return
if (registration.options?.ignoreInputs !== false && isEditableTarget(event.target))
return
if (registration.options?.preventDefault !== false)
event.preventDefault()
if (registration.options?.stopPropagation !== false)
event.stopPropagation()
registration.handler(event)
}
describe('useShortcuts', () => {
beforeEach(() => {
keyPressRegistrations.length = 0
vi.clearAllMocks()
mockUseKeyHold.mockReturnValue(false)
mockGetNodes.mockReturnValue([])
})
it('deletes selected nodes and edges only outside editable inputs', () => {
renderWorkflowHook(() => useShortcuts())
renderWorkflowHook(() => useWorkflowHotkeys())
const deleteShortcut = findRegistration(registration =>
Array.isArray(registration.keyFilter)
&& registration.keyFilter.includes('delete'),
)
const deleteShortcut = findRegistration(registration => registration.keyFilter === 'Delete')
const bodyEvent = createKeyboardEvent()
deleteShortcut.handler(bodyEvent)
triggerShortcut(deleteShortcut, bodyEvent)
expect(bodyEvent.preventDefault).toHaveBeenCalled()
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
const inputEvent = createKeyboardEvent(document.createElement('input'))
deleteShortcut.handler(inputEvent)
triggerShortcut(deleteShortcut, inputEvent)
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
})
it('runs layout and zoom shortcuts through the workflow actions', () => {
renderWorkflowHook(() => useShortcuts())
renderWorkflowHook(() => useWorkflowHotkeys())
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.o' || registration.keyFilter === 'meta.o')
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.1' || registration.keyFilter === 'meta.1')
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'shift.5')
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.dash' || registration.keyFilter === 'meta.dash')
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.equalsign' || registration.keyFilter === 'meta.equalsign')
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+O')
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'Mod+1')
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'Shift+5')
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+-')
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'Mod+=')
layoutShortcut.handler(createKeyboardEvent())
fitViewShortcut.handler(createKeyboardEvent())
halfZoomShortcut.handler(createKeyboardEvent())
zoomOutShortcut.handler(createKeyboardEvent())
zoomInShortcut.handler(createKeyboardEvent())
triggerShortcut(layoutShortcut)
triggerShortcut(fitViewShortcut)
triggerShortcut(halfZoomShortcut)
triggerShortcut(zoomOutShortcut)
triggerShortcut(zoomInShortcut)
expect(mockHandleLayout).toHaveBeenCalledTimes(1)
expect(mockFitView).toHaveBeenCalledTimes(1)
@ -176,11 +242,11 @@ describe('useShortcuts', () => {
},
])
renderWorkflowHook(() => useShortcuts())
renderWorkflowHook(() => useWorkflowHotkeys())
const copyShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.c' || registration.keyFilter === 'meta.c')
const copyShortcut = findRegistration(registration => registration.keyFilter === 'Mod+C')
const event = createKeyboardEvent()
copyShortcut.handler(event)
triggerShortcut(copyShortcut, event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
@ -188,28 +254,44 @@ describe('useShortcuts', () => {
getSelectionSpy.mockRestore()
})
it('dims on shift down, undims on shift up, and responds to zen toggle events', () => {
const { unmount } = renderWorkflowHook(() => useShortcuts())
it('dims while shift is held, undims when released, and responds to zen toggle events', () => {
const { rerender, unmount } = renderWorkflowHook(() => useWorkflowHotkeys())
const shiftDownShortcut = findRegistration(registration => registration.keyFilter === 'shift' && registration.options?.events?.[0] === 'keydown')
const shiftUpShortcut = findRegistration(registration => typeof registration.keyFilter === 'function' && registration.options?.events?.[0] === 'keyup')
mockUseKeyHold.mockReturnValue(true)
rerender()
shiftDownShortcut.handler(createKeyboardEvent())
shiftUpShortcut.handler({ ...createKeyboardEvent(), key: 'Shift' } as KeyboardEvent)
mockUseKeyHold.mockReturnValue(false)
rerender()
expect(mockDimOtherNodes).toHaveBeenCalledTimes(1)
expect(mockUndimAllNodes).toHaveBeenCalledTimes(1)
act(() => {
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
})
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
unmount()
act(() => {
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
})
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
})
it('does not dim when shift is held inside editable inputs', () => {
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
const { rerender } = renderWorkflowHook(() => useWorkflowHotkeys())
mockUseKeyHold.mockReturnValue(true)
rerender()
expect(mockDimOtherNodes).not.toHaveBeenCalled()
expect(mockUndimAllNodes).not.toHaveBeenCalled()
input.remove()
})
})

View File

@ -125,8 +125,14 @@ describe('useWorkflowHistory', () => {
result.current.onRedo(onRedo)
})
const undoSpy = vi.spyOn(result.current.store.temporal.getState(), 'undo')
const redoSpy = vi.spyOn(result.current.store.temporal.getState(), 'redo')
const temporalState = result.current.store.temporal.getState()
const undoSpy = vi.fn()
const redoSpy = vi.fn()
vi.spyOn(result.current.store.temporal, 'getState').mockReturnValue({
...temporalState,
undo: undoSpy,
redo: redoSpy,
})
act(() => {
result.current.undo()

View File

@ -13,7 +13,6 @@ export * from './use-panel-interactions'
export * from './use-selection-interactions'
export * from './use-serial-async-callback'
export * from './use-set-workflow-vars-with-value'
export * from './use-shortcuts'
export * from './use-tool-icon'
export * from './use-workflow'
export * from './use-workflow-comment'

View File

@ -1,293 +0,0 @@
import { useKeyPress } from 'ahooks'
import { useCallback, useEffect } from 'react'
import { useReactFlow } from 'reactflow'
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
import {
useEdgesInteractions,
useNodesInteractions,
useNodesSyncDraft,
useWorkflowCanvasMaximize,
useWorkflowMoveMode,
useWorkflowOrganize,
} from '.'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useWorkflowStore } from '../store'
import {
getKeyboardKeyCodeBySystem,
isEventTargetInputArea,
} from '../utils'
import { useWorkflowHistoryStore } from '../workflow-history-store'
export const useShortcuts = (): void => {
const {
handleNodesCopy,
handleNodesPaste,
handleNodesDuplicate,
handleNodesDelete,
handleHistoryBack,
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
} = useNodesInteractions()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleEdgeDelete } = useEdgesInteractions()
const workflowStore = useWorkflowStore()
const {
handleModeHand,
handleModePointer,
handleModeComment,
isCommentModeAvailable,
} = useWorkflowMoveMode()
const { handleLayout } = useWorkflowOrganize()
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
const {
zoomTo,
getZoom,
fitView,
getNodes,
} = useReactFlow()
// Zoom out to a minimum of 0.25 for shortcut
const constrainedZoomOut = () => {
const currentZoom = getZoom()
const newZoom = Math.max(currentZoom - 0.1, 0.25)
zoomTo(newZoom)
}
// Zoom in to a maximum of 2 for shortcut
const constrainedZoomIn = () => {
const currentZoom = getZoom()
const newZoom = Math.min(currentZoom + 0.1, 2)
zoomTo(newZoom)
}
const shouldHandleShortcut = useCallback((e: KeyboardEvent) => {
return !isEventTargetInputArea(e.target as HTMLElement)
}, [])
const shouldHandleCopy = useCallback(() => {
// Box selection can leave incidental DOM text selection behind while the
// workflow selection itself lives on node.data._isBundled.
if (getNodes().some(node => node.data._isBundled))
return true
const selection = document.getSelection()
return !selection || selection.isCollapsed || !selection.rangeCount
}, [getNodes])
useKeyPress(['delete', 'backspace'], (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleNodesDelete()
handleEdgeDelete()
}
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
e.preventDefault()
handleNodesCopy()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
e.preventDefault()
handleNodesPaste()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleNodesDuplicate()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
// @ts-expect-error - Dynamic property added by run-and-history component
if (window._toggleTestRunDropdown) {
// @ts-expect-error - Dynamic property added by run-and-history component
window._toggleTestRunDropdown()
}
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.z`, (e) => {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
e.preventDefault()
if (workflowHistoryShortcutsEnabled)
handleHistoryBack()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(
[`${getKeyboardKeyCodeBySystem('ctrl')}.y`, `${getKeyboardKeyCodeBySystem('ctrl')}.shift.z`],
(e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
if (workflowHistoryShortcutsEnabled)
handleHistoryForward()
}
},
{ exactMatch: true, useCapture: true },
)
useKeyPress('h', (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleModeHand()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('v', (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleModePointer()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('c', (e) => {
if (shouldHandleShortcut(e) && isCommentModeAvailable) {
e.preventDefault()
handleModeComment()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.o`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleLayout()
}
}, { exactMatch: true, useCapture: true })
useKeyPress('f', (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleToggleMaximizeCanvas()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
fitView()
handleSyncWorkflowDraft()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('shift.1', (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
zoomTo(1)
handleSyncWorkflowDraft()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('shift.5', (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
zoomTo(0.5)
handleSyncWorkflowDraft()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
constrainedZoomOut()
handleSyncWorkflowDraft()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
constrainedZoomIn()
handleSyncWorkflowDraft()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.l`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
collaborationManager.downloadGraphImportLog()
}
}, { exactMatch: true, useCapture: true })
// Shift ↓
useKeyPress(
'shift',
(e) => {
if (shouldHandleShortcut(e))
dimOtherNodes()
},
{
exactMatch: true,
useCapture: true,
events: ['keydown'],
},
)
// Shift ↑
useKeyPress(
(e) => {
return e.key === 'Shift'
},
(e) => {
if (shouldHandleShortcut(e))
undimAllNodes()
},
{
exactMatch: true,
useCapture: true,
events: ['keyup'],
},
)
// Listen for zen toggle event from /zen command
useEffect(() => {
const handleZenToggle = () => {
handleToggleMaximizeCanvas()
}
window.addEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
return () => {
window.removeEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
}
}, [handleToggleMaximizeCanvas])
}

View File

@ -1,4 +1,4 @@
import type { WorkflowHistoryEventMeta } from '../workflow-history-store'
import type { WorkflowHistoryEventMeta } from '../store/workflow/history-slice'
import { debounce } from 'es-toolkit/compat'
import {
useCallback,

View File

@ -88,7 +88,6 @@ import {
usePanelInteractions,
useSelectionInteractions,
useSetWorkflowVarsWithValue,
useShortcuts,
useWorkflow,
useWorkflowReadOnly,
useWorkflowRefreshDraft,
@ -111,19 +110,19 @@ import Operator from './operator'
import Control from './operator/control'
import PanelContextmenu from './panel-contextmenu'
import SelectionContextmenu from './selection-contextmenu'
import { useWorkflowHotkeys } from './shortcuts/use-workflow-hotkeys'
import CustomSimpleNode from './simple-node'
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
import {
useStore,
useWorkflowStore,
} from './store'
} from './store/workflow'
import SyncingDataModal from './syncing-data-modal'
import {
ControlMode,
WorkflowRunningStatus,
} from './types'
import { setupScrollToNodeListener } from './utils/node-navigation'
import { WorkflowHistoryProvider } from './workflow-history-store'
import 'reactflow/dist/style.css'
import './style.css'
@ -530,7 +529,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
},
})
useShortcuts()
useWorkflowHotkeys()
// Initialize workflow node search functionality
useWorkflowSearch()
@ -794,6 +793,30 @@ type WorkflowWithDefaultContextProps
children: React.ReactNode
}
const WorkflowHistoryStoreInitializer = ({
nodes,
edges,
children,
}: WorkflowWithDefaultContextProps) => {
const workflowStore = useWorkflowStore()
const initializedRef = useRef(false)
if (!initializedRef.current) {
workflowStore.temporal.getState().pause()
workflowStore.getState().setWorkflowHistory({
nodes,
edges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
})
workflowStore.temporal.getState().clear()
workflowStore.temporal.getState().resume()
initializedRef.current = true
}
return children
}
const WorkflowWithDefaultContext = ({
nodes,
edges,
@ -801,14 +824,14 @@ const WorkflowWithDefaultContext = ({
}: WorkflowWithDefaultContextProps) => {
return (
<ReactFlowProvider>
<WorkflowHistoryProvider
<WorkflowHistoryStoreInitializer
nodes={nodes}
edges={edges}
>
<DatasetsDetailProvider nodes={nodes}>
{children}
</DatasetsDetailProvider>
</WorkflowHistoryProvider>
</WorkflowHistoryStoreInitializer>
</ReactFlowProvider>
)
}

View File

@ -35,10 +35,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => {
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: React.ReactElement<{ children?: ReactNode }> }) => {
const { open, setOpen } = useDropdownMenuContext()
if (render)
return <div onClick={() => setOpen(!open)}>{children}</div>
if (render) {
return React.cloneElement(
render,
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
children ?? render.props.children,
)
}
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
},
@ -50,8 +55,8 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
})
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, className }: { children: ReactNode, className?: string }) => (
<button type="button" className={className}>
Button: ({ children, className, onClick }: { children: ReactNode, className?: string, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => (
<button type="button" className={className} onClick={onClick}>
{children}
</button>
),

View File

@ -89,11 +89,13 @@ const Operator = ({
open={open}
onOpenChange={onOpenChange}
>
<DropdownMenuTrigger render={<div />}>
<Button className="h-6 w-6 p-0">
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<Button className="h-6 w-6 p-0" aria-label={t('common.moreActions', { ns: 'workflow' })}>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</Button>
)}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -13,7 +13,7 @@ import {
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { BlockEnum } from '@/app/components/workflow/types'
import {
canRunBySingle,
@ -67,9 +67,10 @@ const PanelOperatorPopup = ({
<div className="p-1">
{
canRunBySingle(data.type, isChildNode) && (
<div
<button
type="button"
className={`
flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
hover:bg-state-base-hover
`}
onClick={() => {
@ -80,7 +81,7 @@ const PanelOperatorPopup = ({
}}
>
{t('panel.runThisStep', { ns: 'workflow' })}
</div>
</button>
)
}
{
@ -104,26 +105,28 @@ const PanelOperatorPopup = ({
!nodeMetaData.isSingleton && (
<>
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onClosePopup()
handleNodesCopy(id)
}}
>
{t('common.copy', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'c']} />
</div>
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<ShortcutKbd shortcut="workflow.copy" />
</button>
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onClosePopup()
handleNodesDuplicate(id)
}}
>
{t('common.duplicate', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'd']} />
</div>
<ShortcutKbd shortcut="workflow.duplicate" />
</button>
</div>
<div className="h-px bg-divider-regular"></div>
</>
@ -133,16 +136,17 @@ const PanelOperatorPopup = ({
!nodeMetaData.isUndeletable && (
<>
<div className="p-1">
<div
<button
type="button"
className={`
flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
hover:bg-state-destructive-hover hover:text-text-destructive
`}
onClick={() => handleNodeDelete(id)}
>
{t('operation.delete', { ns: 'common' })}
<ShortcutsName keys={['del']} />
</div>
<ShortcutKbd shortcut="workflow.delete" />
</button>
</div>
<div className="h-px bg-divider-regular"></div>
</>

View File

@ -43,7 +43,7 @@ const Field: FC<Props> = ({
disabled={depth !== MAX_DEPTH + 1}
render={(
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
className={cn('flex items-center justify-between rounded-md pr-2 outline-none focus:outline-none focus-visible:outline-none', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className="flex grow items-stretch">

View File

@ -29,6 +29,7 @@ import {
} from './var-reference-vars.helpers'
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
const resolveValueSelector = ({
itemData,
@ -210,7 +211,7 @@ const Item: FC<ItemProps> = ({
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-none focus:outline-none focus-visible:outline-none',
className,
)}
data-selected={isSelected ? 'true' : 'false'}
@ -263,7 +264,7 @@ const Item: FC<ItemProps> = ({
<PopoverContent
placement="left-start"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
popupClassName={cn(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, 'border-none bg-transparent p-0 shadow-none backdrop-blur-none')}
positionerProps={{
style: {
zIndex: zIndex || 100,

View File

@ -284,13 +284,15 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
)}
</div>
<NodeHeaderMeta
data={data}
hasVarValue={hasVarValue}
isLoading={isLoading}
loopIndex={LoopIndex}
t={t}
/>
<div className="flex shrink-0 items-center">
<NodeHeaderMeta
data={data}
hasVarValue={hasVarValue}
isLoading={isLoading}
loopIndex={LoopIndex}
t={t}
/>
</div>
</div>
<NodeBody
data={data}

View File

@ -1,10 +1,9 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useWorkflowShortcut } from '@/app/components/workflow/shortcuts/use-workflow-hotkeys'
type AdvancedActionsProps = {
isConfirmDisabled: boolean
@ -19,12 +18,11 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
}) => {
const { t } = useTranslation()
useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
e.preventDefault()
useWorkflowShortcut('workflow.json-schema-confirm', () => {
onConfirm()
}, {
exactMatch: true,
useCapture: true,
enabled: !isConfirmDisabled,
ignoreInputs: false,
})
return (
@ -40,7 +38,7 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
onClick={onConfirm}
>
<span>{t('operation.confirm', { ns: 'common' })}</span>
<ShortcutsName keys={['ctrl', '⏎']} bgColor="white" />
<ShortcutKbd shortcut="workflow.json-schema-confirm" bgColor="white" />
</Button>
</div>
)

View File

@ -14,7 +14,6 @@ const {
mockHandleNodesDuplicate,
mockHandleShowAuthorChange,
mockHandleThemeChange,
mockSetShortcutsEnabled,
} = vi.hoisted(() => ({
mockHandleEditorChange: vi.fn(),
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
@ -23,7 +22,6 @@ const {
mockHandleNodesDuplicate: vi.fn(),
mockHandleShowAuthorChange: vi.fn(),
mockHandleThemeChange: vi.fn(),
mockSetShortcutsEnabled: vi.fn(),
}))
vi.mock('../../hooks', async (importOriginal) => {
@ -49,12 +47,6 @@ vi.mock('../hooks', () => ({
}),
}))
vi.mock('../../workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
setShortcutsEnabled: mockSetShortcutsEnabled,
}),
}))
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
title: '',
desc: '',

View File

@ -12,8 +12,7 @@ import {
useNodesInteractions,
} from '../hooks'
import NodeResizer from '../nodes/_base/components/node-resizer'
import { useStore } from '../store'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useStore } from '../store/workflow'
import { THEME_MAP } from './constants'
import { useNote } from './hooks'
import {
@ -36,6 +35,7 @@ const NoteNode = ({
}: NodeProps<NoteNodeType>) => {
const { t } = useTranslation()
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
const setHistoryShortcutsEnabled = useStore(s => s.setHistoryShortcutsEnabled)
const ref = useRef<HTMLDivElement | null>(null)
const theme = data.theme
const {
@ -54,8 +54,6 @@ const NoteNode = ({
handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } })
}, ref)
const { setShortcutsEnabled } = useWorkflowHistoryStore()
return (
<div
className={cn(
@ -117,7 +115,7 @@ const NoteNode = ({
containerElement={ref.current}
placeholder={t('nodes.note.editor.placeholder', { ns: 'workflow' }) || ''}
onChange={handleEditorChange}
setShortcutsEnabled={setShortcutsEnabled}
setHistoryShortcutsEnabled={setHistoryShortcutsEnabled}
/>
</div>
</div>

View File

@ -93,17 +93,17 @@ describe('Editor', () => {
// Focus and blur should toggle workflow shortcuts while editing content.
describe('Focus Management', () => {
it('should disable shortcuts on focus and re-enable them on blur-sm', () => {
const setShortcutsEnabled = vi.fn()
const setHistoryShortcutsEnabled = vi.fn()
renderEditor({ setShortcutsEnabled })
renderEditor({ setHistoryShortcutsEnabled })
const contentEditable = screen.getByRole('textbox')
fireEvent.focus(contentEditable)
fireEvent.blur(contentEditable)
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
expect(setHistoryShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
expect(setHistoryShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
})
})

View File

@ -22,13 +22,13 @@ type EditorProps = {
placeholder?: string
onChange?: (editorState: EditorState) => void
containerElement: HTMLDivElement | null
setShortcutsEnabled?: (v: boolean) => void
setHistoryShortcutsEnabled?: (v: boolean) => void
}
const Editor = ({
placeholder = 'write you note...',
onChange,
containerElement,
setShortcutsEnabled,
setHistoryShortcutsEnabled,
}: EditorProps) => {
const handleEditorChange = useCallback((editorState: EditorState) => {
onChange?.(editorState)
@ -40,8 +40,8 @@ const Editor = ({
contentEditable={(
<div>
<ContentEditable
onFocus={() => setShortcutsEnabled?.(false)}
onBlur={() => setShortcutsEnabled?.(true)}
onFocus={() => setHistoryShortcutsEnabled?.(false)}
onBlur={() => setHistoryShortcutsEnabled?.(true)}
spellCheck={false}
className="h-full w-full text-text-secondary caret-primary-600 outline-hidden"
/>

View File

@ -12,7 +12,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
export type OperatorProps = {
onCopy: () => void
@ -69,7 +69,7 @@ const Operator = ({
}}
>
{t('common.copy', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'c']} />
<ShortcutKbd shortcut="workflow.copy" />
</DropdownMenuItem>
<DropdownMenuItem
className="justify-between rounded-md px-3 text-sm text-text-secondary"
@ -79,7 +79,7 @@ const Operator = ({
}}
>
{t('common.duplicate', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'd']} />
<ShortcutKbd shortcut="workflow.duplicate" />
</DropdownMenuItem>
</div>
<DropdownMenuSeparator className="my-0" />
@ -107,7 +107,7 @@ const Operator = ({
}}
>
{t('operation.delete', { ns: 'common' })}
<ShortcutsName keys={['del']} />
<ShortcutKbd shortcut="workflow.delete" />
</DropdownMenuItem>
</div>
</div>

View File

@ -47,7 +47,7 @@ const Control = () => {
} = useNodesReadOnly()
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
const addNote = (e: MouseEvent<HTMLDivElement>) => {
const addNote = (e: MouseEvent<HTMLButtonElement>) => {
if (getNodesReadOnly())
return
@ -59,19 +59,25 @@ const Control = () => {
<div className="pointer-events-auto flex flex-col items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-text-tertiary shadow-lg">
<AddBlock />
<TipPopup title={t('nodes.note.addNote', { ns: 'workflow' })}>
<div
<button
type="button"
aria-label={t('nodes.note.addNote', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={cn(
'ml-px flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
)}
onClick={addNote}
>
<RiStickyNoteAddLine className="h-4 w-4" />
</div>
<RiStickyNoteAddLine aria-hidden className="h-4 w-4" />
</button>
</TipPopup>
<Divider className="my-1 w-3.5" />
<TipPopup title={t('common.pointerMode', { ns: 'workflow' })} shortcuts={['v']}>
<div
<TipPopup title={t('common.pointerMode', { ns: 'workflow' })} shortcut="workflow.pointer-mode">
<button
type="button"
aria-label={t('common.pointerMode', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={cn(
'mr-px flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
controlMode === ControlMode.Pointer ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
@ -79,11 +85,14 @@ const Control = () => {
)}
onClick={handleModePointer}
>
<RiCursorLine className="h-4 w-4" />
</div>
<RiCursorLine aria-hidden className="h-4 w-4" />
</button>
</TipPopup>
<TipPopup title={t('common.handMode', { ns: 'workflow' })} shortcuts={['h']}>
<div
<TipPopup title={t('common.handMode', { ns: 'workflow' })} shortcut="workflow.hand-mode">
<button
type="button"
aria-label={t('common.handMode', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
controlMode === ControlMode.Hand ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
@ -91,12 +100,15 @@ const Control = () => {
)}
onClick={handleModeHand}
>
<RiHand className="h-4 w-4" />
</div>
<RiHand aria-hidden className="h-4 w-4" />
</button>
</TipPopup>
{isCommentModeAvailable && (
<TipPopup title={t('common.commentMode', { ns: 'workflow' })} shortcuts={['c']}>
<div
<TipPopup title={t('common.commentMode', { ns: 'workflow' })} shortcut="workflow.comment-mode">
<button
type="button"
aria-label={t('common.commentMode', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={cn(
'ml-[1px] flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
controlMode === ControlMode.Comment ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
@ -104,24 +116,30 @@ const Control = () => {
)}
onClick={handleModeComment}
>
<Comment className="h-4 w-4" />
</div>
<Comment aria-hidden className="h-4 w-4" />
</button>
</TipPopup>
)}
<Divider className="my-1 w-3.5" />
<TipPopup title={t('panel.organizeBlocks', { ns: 'workflow' })} shortcuts={['ctrl', 'o']}>
<div
<TipPopup title={t('panel.organizeBlocks', { ns: 'workflow' })} shortcut="workflow.organize">
<button
type="button"
aria-label={t('panel.organizeBlocks', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
)}
onClick={handleLayout}
>
<RiFunctionAddLine className="h-4 w-4" />
</div>
<RiFunctionAddLine aria-hidden className="h-4 w-4" />
</button>
</TipPopup>
<TipPopup title={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })} shortcuts={['f']}>
<div
<TipPopup title={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })} shortcut="workflow.toggle-maximize">
<button
type="button"
aria-label={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
maximizeCanvas ? 'bg-state-accent-active text-text-accent hover:text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
@ -129,9 +147,9 @@ const Control = () => {
)}
onClick={handleToggleMaximizeCanvas}
>
{maximizeCanvas && <RiAspectRatioFill className="h-4 w-4" />}
{!maximizeCanvas && <RiAspectRatioLine className="h-4 w-4" />}
</div>
{maximizeCanvas && <RiAspectRatioFill aria-hidden className="h-4 w-4" />}
{!maximizeCanvas && <RiAspectRatioLine aria-hidden className="h-4 w-4" />}
</button>
</TipPopup>
<MoreActions />
</div>

View File

@ -1,32 +1,37 @@
import type { ReactElement } from 'react'
import type { WorkflowShortcutId } from '../shortcuts/definitions'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { memo } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import ShortcutsName from '../shortcuts-name'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
type TipPopupProps = {
title: string
children: React.ReactNode
shortcuts?: string[]
children: ReactElement
shortcut?: WorkflowShortcutId
}
const TipPopup = ({
title,
children,
shortcuts,
shortcut,
}: TipPopupProps) => {
return (
<Tooltip
needsDelay={false}
offset={4}
popupClassName="p-0 bg-transparent"
popupContent={(
<Tooltip>
<TooltipTrigger render={children} />
<TooltipContent
sideOffset={4}
className="max-w-none bg-transparent p-0 shadow-none"
>
<div className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg backdrop-blur-[5px]">
<span className="system-xs-medium text-text-secondary">{title}</span>
{
shortcuts && <ShortcutsName keys={shortcuts} />
shortcut && <ShortcutKbd shortcut={shortcut} />
}
</div>
)}
>
{children}
</TooltipContent>
</Tooltip>
)
}

View File

@ -23,7 +23,7 @@ import {
useNodesSyncDraft,
useWorkflowReadOnly,
} from '../hooks'
import ShortcutsName from '../shortcuts-name'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
import TipPopup from './tip-popup'
enum ZoomType {
@ -181,9 +181,12 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
<TipPopup
title={t('operator.zoomOut', { ns: 'workflow' })}
shortcuts={['ctrl', '-']}
shortcut="workflow.zoom-out"
>
<div
<button
type="button"
aria-label={t('operator.zoomOut', { ns: 'workflow' })}
disabled={zoom <= 0.25}
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
onClick={(e) => {
if (zoom <= 0.25)
@ -194,7 +197,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
}}
>
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
</div>
</button>
</TipPopup>
<DropdownMenu
open={open}
@ -262,13 +265,13 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
</div>
<div className="flex items-center space-x-0.5">
{option.key === ZoomType.zoomToFit && (
<ShortcutsName keys={['ctrl', '1']} />
<ShortcutKbd shortcut="workflow.zoom-to-fit" />
)}
{option.key === ZoomType.zoomTo50 && (
<ShortcutsName keys={['shift', '5']} />
<ShortcutKbd shortcut="workflow.zoom-to-50" />
)}
{option.key === ZoomType.zoomTo100 && (
<ShortcutsName keys={['shift', '1']} />
<ShortcutKbd shortcut="workflow.zoom-to-100" />
)}
</div>
</DropdownMenuItem>
@ -281,9 +284,12 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
</DropdownMenu>
<TipPopup
title={t('operator.zoomIn', { ns: 'workflow' })}
shortcuts={['ctrl', '+']}
shortcut="workflow.zoom-in"
>
<div
<button
type="button"
aria-label={t('operator.zoomIn', { ns: 'workflow' })}
disabled={zoom >= 2}
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
onClick={(e) => {
if (zoom >= 2)
@ -294,7 +300,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
}}
>
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
</div>
</button>
</TipPopup>
</div>
</div>

View File

@ -15,7 +15,7 @@ import {
} from './hooks'
import AddBlock from './operator/add-block'
import { useOperator } from './operator/hooks'
import ShortcutsName from './shortcuts-name'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
import { useStore } from './store'
const PanelContextmenu = () => {
@ -40,11 +40,12 @@ const PanelContextmenu = () => {
const renderTrigger = () => {
return (
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
>
{t('common.addBlock', { ns: 'workflow' })}
</div>
</button>
)
}
@ -68,8 +69,9 @@ const PanelContextmenu = () => {
crossAxis: -4,
}}
/>
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
handleAddNote()
@ -77,11 +79,13 @@ const PanelContextmenu = () => {
}}
>
{t('nodes.note.addNote', { ns: 'workflow' })}
</div>
</button>
{isCommentModeAvailable && (
<div
<button
type="button"
disabled={!!pendingComment}
className={cn(
'flex h-8 items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
'flex h-8 w-full items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
pendingComment ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={(e) => {
@ -94,24 +98,27 @@ const PanelContextmenu = () => {
}}
>
{t('comments.actions.addComment', { ns: 'workflow' })}
</div>
</button>
)}
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
handleStartWorkflowRun()
handlePaneContextmenuCancel()
}}
>
{t('common.run', { ns: 'workflow' })}
<ShortcutsName keys={['alt', 'r']} />
</div>
<ShortcutKbd shortcut="workflow.open-test-run-menu" />
</button>
</div>
<Divider className="m-0" />
<div className="p-1">
<div
<button
type="button"
disabled={!clipboardElements.length}
className={cn(
'flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
'flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
!clipboardElements.length ? 'cursor-not-allowed opacity-50' : 'hover:bg-state-base-hover',
)}
onClick={() => {
@ -122,23 +129,25 @@ const PanelContextmenu = () => {
}}
>
{t('common.pasteHere', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'v']} />
</div>
<ShortcutKbd shortcut="workflow.paste" />
</button>
</div>
<Divider className="m-0" />
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => exportCheck?.()}
>
{t('export', { ns: 'app' })}
</div>
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
</button>
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</div>
</button>
</div>
</div>
)

View File

@ -20,7 +20,7 @@ import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-co
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import ShortcutsName from './shortcuts-name'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
import { useStore, useWorkflowStore } from './store'
const AlignType = {
@ -387,7 +387,7 @@ const SelectionContextmenu = () => {
onClick={handleCopyNodes}
>
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'c']} />
<ShortcutKbd shortcut="workflow.copy" />
</ContextMenuItem>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
@ -395,7 +395,7 @@ const SelectionContextmenu = () => {
onClick={handleDuplicateNodes}
>
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'd']} />
<ShortcutKbd shortcut="workflow.duplicate" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
@ -406,7 +406,7 @@ const SelectionContextmenu = () => {
onClick={handleDeleteNodes}
>
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
<ShortcutsName keys={['del']} />
<ShortcutKbd shortcut="workflow.delete" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />

View File

@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react'
import { ShortcutKbd } from '../shortcut-kbd'
describe('ShortcutKbd', () => {
it('renders shortcut chords as separate keycaps with the legacy visual classes', () => {
const { container } = render(
<ShortcutKbd
shortcut="workflow.copy"
platform="mac"
bgColor="white"
textColor="secondary"
className="ml-2"
/>,
)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-0.5', 'ml-2')
const keys = container.querySelectorAll('kbd')
expect(keys).toHaveLength(2)
expect(screen.getByText('⌘')).toBeInTheDocument()
expect(screen.getByText('C')).toBeInTheDocument()
expect(keys[0]).toHaveClass(
'h-4',
'min-w-4',
'rounded-sm',
'font-sans',
'not-italic',
'system-kbd',
'capitalize',
'bg-components-kbd-bg-white',
'text-text-tertiary',
)
})
it('keeps single-key shortcuts in one keycap', () => {
const { container } = render(
<ShortcutKbd shortcut="workflow.delete" platform="windows" />,
)
expect(container.querySelectorAll('kbd')).toHaveLength(1)
expect(screen.getByText('⌦')).toBeInTheDocument()
})
it('uses TanStack non-mac modifier labels', () => {
render(<ShortcutKbd shortcut="workflow.copy" platform="windows" />)
expect(screen.getByText('Ctrl')).toBeInTheDocument()
expect(screen.getByText('C')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,19 @@
export const WorkflowCommand = {
ToggleCanvasMaximize: 'workflow:toggle-canvas-maximize',
} as const
type WorkflowCommandType = typeof WorkflowCommand[keyof typeof WorkflowCommand]
const workflowCommandTarget = new EventTarget()
export const emitWorkflowCommand = (command: WorkflowCommandType) => {
workflowCommandTarget.dispatchEvent(new Event(command))
}
export const subscribeWorkflowCommand = (
command: WorkflowCommandType,
listener: () => void,
) => {
workflowCommandTarget.addEventListener(command, listener)
return () => workflowCommandTarget.removeEventListener(command, listener)
}

View File

@ -0,0 +1,177 @@
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
export type WorkflowShortcutId
= | 'workflow.delete'
| 'workflow.copy'
| 'workflow.paste'
| 'workflow.duplicate'
| 'workflow.open-test-run-menu'
| 'workflow.undo'
| 'workflow.redo'
| 'workflow.pointer-mode'
| 'workflow.hand-mode'
| 'workflow.comment-mode'
| 'workflow.organize'
| 'workflow.toggle-maximize'
| 'workflow.zoom-to-fit'
| 'workflow.zoom-to-100'
| 'workflow.zoom-to-50'
| 'workflow.zoom-out'
| 'workflow.zoom-in'
| 'workflow.download-import-log'
| 'workflow.dim-other-nodes'
| 'workflow.json-schema-confirm'
| 'workflow.version-history'
export type WorkflowHotkeyMeta = {
id: WorkflowShortcutId
scope: 'workflow'
name: string
description: string
}
export type WorkflowShortcutDefinition = {
id: WorkflowShortcutId
hotkeys: readonly RegisterableHotkey[]
displayHotkey?: RegisterableHotkey | (string & {})
name: string
description: string
}
export const WORKFLOW_SHORTCUTS: Record<WorkflowShortcutId, WorkflowShortcutDefinition> = {
'workflow.delete': {
id: 'workflow.delete',
hotkeys: ['Delete', 'Backspace'],
displayHotkey: 'Delete',
name: 'Delete selection',
description: 'Delete selected workflow nodes or edges',
},
'workflow.copy': {
id: 'workflow.copy',
hotkeys: ['Mod+C'],
name: 'Copy',
description: 'Copy selected workflow nodes',
},
'workflow.paste': {
id: 'workflow.paste',
hotkeys: ['Mod+V'],
name: 'Paste',
description: 'Paste copied workflow nodes',
},
'workflow.duplicate': {
id: 'workflow.duplicate',
hotkeys: ['Mod+D'],
name: 'Duplicate',
description: 'Duplicate selected workflow nodes',
},
'workflow.open-test-run-menu': {
id: 'workflow.open-test-run-menu',
hotkeys: ['Alt+R'],
name: 'Open test run menu',
description: 'Open the workflow test run menu',
},
'workflow.undo': {
id: 'workflow.undo',
hotkeys: ['Mod+Z'],
name: 'Undo',
description: 'Undo the previous workflow change',
},
'workflow.redo': {
id: 'workflow.redo',
hotkeys: ['Mod+Y', 'Mod+Shift+Z'],
displayHotkey: 'Mod+Y',
name: 'Redo',
description: 'Redo the next workflow change',
},
'workflow.pointer-mode': {
id: 'workflow.pointer-mode',
hotkeys: ['V'],
name: 'Pointer mode',
description: 'Switch to pointer mode',
},
'workflow.hand-mode': {
id: 'workflow.hand-mode',
hotkeys: ['H'],
name: 'Hand mode',
description: 'Switch to hand mode',
},
'workflow.comment-mode': {
id: 'workflow.comment-mode',
hotkeys: ['C'],
name: 'Comment mode',
description: 'Switch to comment mode',
},
'workflow.organize': {
id: 'workflow.organize',
hotkeys: ['Mod+O'],
name: 'Organize blocks',
description: 'Automatically organize workflow blocks',
},
'workflow.toggle-maximize': {
id: 'workflow.toggle-maximize',
hotkeys: ['F'],
name: 'Toggle maximize',
description: 'Maximize or minimize the workflow canvas',
},
'workflow.zoom-to-fit': {
id: 'workflow.zoom-to-fit',
hotkeys: ['Mod+1'],
name: 'Zoom to fit',
description: 'Fit the workflow canvas into view',
},
'workflow.zoom-to-100': {
id: 'workflow.zoom-to-100',
hotkeys: ['Shift+1'],
name: 'Zoom to 100%',
description: 'Zoom the workflow canvas to 100%',
},
'workflow.zoom-to-50': {
id: 'workflow.zoom-to-50',
hotkeys: ['Shift+5'],
name: 'Zoom to 50%',
description: 'Zoom the workflow canvas to 50%',
},
'workflow.zoom-out': {
id: 'workflow.zoom-out',
hotkeys: ['Mod+-'],
name: 'Zoom out',
description: 'Zoom out of the workflow canvas',
},
'workflow.zoom-in': {
id: 'workflow.zoom-in',
hotkeys: ['Mod+='],
displayHotkey: 'Mod+=',
name: 'Zoom in',
description: 'Zoom into the workflow canvas',
},
'workflow.download-import-log': {
id: 'workflow.download-import-log',
hotkeys: ['Mod+Shift+L'],
name: 'Download import log',
description: 'Download the workflow graph import log',
},
'workflow.dim-other-nodes': {
id: 'workflow.dim-other-nodes',
hotkeys: [{ key: 'Shift', shift: true }],
displayHotkey: 'Shift',
name: 'Dim other nodes',
description: 'Dim nodes outside the current workflow selection',
},
'workflow.json-schema-confirm': {
id: 'workflow.json-schema-confirm',
hotkeys: ['Mod+Enter'],
name: 'Confirm JSON schema edit',
description: 'Confirm the current JSON schema edit',
},
'workflow.version-history': {
id: 'workflow.version-history',
hotkeys: ['Mod+Shift+H'],
name: 'Version history',
description: 'Open workflow version history',
},
}
export const getWorkflowShortcutDisplayHotkey = (id: WorkflowShortcutId): RegisterableHotkey | (string & {}) => {
const shortcut = WORKFLOW_SHORTCUTS[id]
return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
}

View File

@ -0,0 +1,70 @@
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { WorkflowShortcutId } from './definitions'
import { cn } from '@langgenius/dify-ui/cn'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { getWorkflowShortcutDisplayHotkey } from './definitions'
type ShortcutKbdProps = {
shortcut?: WorkflowShortcutId
hotkey?: RegisterableHotkey | (string & {})
className?: string
textColor?: 'default' | 'secondary'
bgColor?: 'gray' | 'white'
platform?: FormatDisplayOptions['platform']
}
const getDisplayKeys = (
hotkey: RegisterableHotkey | (string & {}),
platform?: FormatDisplayOptions['platform'],
) => {
const displayOptions = platform ? { platform } : undefined
if (typeof hotkey !== 'string')
return [formatForDisplay(hotkey, displayOptions)]
return hotkey
.split('+')
.filter(Boolean)
.map(key => formatForDisplay(key, displayOptions))
}
export const ShortcutKbd = ({
shortcut,
hotkey,
className,
textColor = 'default',
bgColor = 'gray',
platform,
}: ShortcutKbdProps) => {
const displayHotkey = hotkey ?? (shortcut ? getWorkflowShortcutDisplayHotkey(shortcut) : undefined)
if (!displayHotkey)
return null
const displayKeys = getDisplayKeys(displayHotkey, platform)
return (
<span
className={cn(
'flex items-center gap-0.5',
className,
)}
>
{
displayKeys.map((key, index) => (
<kbd
key={`${key}-${index}`}
className={cn(
'flex h-4 min-w-4 items-center justify-center rounded-sm px-1 font-sans system-kbd capitalize not-italic',
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
textColor === 'secondary' && 'text-text-tertiary',
)}
>
{key}
</kbd>
))
}
</span>
)
}

View File

@ -0,0 +1,255 @@
import type {
HotkeyCallback,
UseHotkeyDefinition,
UseHotkeyOptions,
} from '@tanstack/react-hotkeys'
import type { WorkflowHotkeyMeta, WorkflowShortcutDefinition, WorkflowShortcutId } from './definitions'
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useReactFlow } from 'reactflow'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
import { useNodesInteractions } from '../hooks/use-nodes-interactions'
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
import { useWorkflowCanvasMaximize } from '../hooks/use-workflow-canvas-maximize'
import { useWorkflowOrganize } from '../hooks/use-workflow-organize'
import { useWorkflowMoveMode } from '../hooks/use-workflow-panel-interactions'
import { useStore } from '../store/workflow'
import { isEventTargetInputArea } from '../utils'
import {
subscribeWorkflowCommand,
WorkflowCommand,
} from './commands'
import { WORKFLOW_SHORTCUTS } from './definitions'
const workflowHotkeyOptions = {
ignoreInputs: true,
conflictBehavior: 'warn',
} satisfies UseHotkeyOptions
const toHotkeyDefinitions = (
shortcut: WorkflowShortcutDefinition,
callback: HotkeyCallback,
options?: UseHotkeyOptions,
): UseHotkeyDefinition[] => {
return shortcut.hotkeys.map(hotkey => ({
hotkey,
callback,
options: {
...options,
meta: {
id: shortcut.id,
scope: 'workflow',
name: shortcut.name,
description: shortcut.description,
} satisfies WorkflowHotkeyMeta,
},
}))
}
export const useWorkflowShortcut = (
id: WorkflowShortcutId,
callback: HotkeyCallback,
options?: UseHotkeyOptions,
) => {
const shortcut = WORKFLOW_SHORTCUTS[id]
const hotkeys = useMemo(
() => toHotkeyDefinitions(shortcut, callback, options),
[callback, options, shortcut],
)
useHotkeys(hotkeys, workflowHotkeyOptions)
}
export const useWorkflowHotkeys = (): void => {
const {
handleNodesCopy,
handleNodesPaste,
handleNodesDuplicate,
handleNodesDelete,
handleHistoryBack,
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
} = useNodesInteractions()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleEdgeDelete } = useEdgesInteractions()
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
const historyShortcutsEnabled = useStore(s => s.historyShortcutsEnabled)
const {
handleModeHand,
handleModePointer,
handleModeComment,
isCommentModeAvailable,
} = useWorkflowMoveMode()
const { handleLayout } = useWorkflowOrganize()
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
const {
zoomTo,
getZoom,
fitView,
getNodes,
} = useReactFlow()
const isShiftHeld = useKeyHold('Shift')
const shiftDimmedRef = useRef(false)
const undimAllNodesRef = useRef(undimAllNodes)
undimAllNodesRef.current = undimAllNodes
const constrainedZoomOut = useCallback(() => {
const currentZoom = getZoom()
const newZoom = Math.max(currentZoom - 0.1, 0.25)
zoomTo(newZoom)
}, [getZoom, zoomTo])
const constrainedZoomIn = useCallback(() => {
const currentZoom = getZoom()
const newZoom = Math.min(currentZoom + 0.1, 2)
zoomTo(newZoom)
}, [getZoom, zoomTo])
const shouldHandleCopy = useCallback(() => {
if (getNodes().some(node => node.data._isBundled))
return true
const selection = document.getSelection()
return !selection || selection.isCollapsed || !selection.rangeCount
}, [getNodes])
const handleCopy = useCallback<HotkeyCallback>((event) => {
if (!shouldHandleCopy())
return
event.preventDefault()
event.stopPropagation()
handleNodesCopy()
}, [handleNodesCopy, shouldHandleCopy])
const handleZenToggle = useCallback(() => {
handleToggleMaximizeCanvas()
}, [handleToggleMaximizeCanvas])
const hotkeys = useMemo<UseHotkeyDefinition[]>(() => [
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.delete'], () => {
handleNodesDelete()
handleEdgeDelete()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.copy'], handleCopy, {
preventDefault: false,
stopPropagation: false,
enabled: !showDebugAndPreviewPanel,
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.paste'], () => {
handleNodesPaste()
}, {
enabled: !showDebugAndPreviewPanel,
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.duplicate'], () => {
handleNodesDuplicate()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.undo'], () => {
handleHistoryBack()
}, {
enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled,
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.redo'], () => {
handleHistoryForward()
}, {
enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled,
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.hand-mode'], () => {
handleModeHand()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.pointer-mode'], () => {
handleModePointer()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.comment-mode'], () => {
handleModeComment()
}, {
enabled: isCommentModeAvailable,
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.organize'], () => {
handleLayout()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.toggle-maximize'], () => {
handleToggleMaximizeCanvas()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-fit'], () => {
fitView()
handleSyncWorkflowDraft()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-100'], () => {
zoomTo(1)
handleSyncWorkflowDraft()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-50'], () => {
zoomTo(0.5)
handleSyncWorkflowDraft()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-out'], () => {
constrainedZoomOut()
handleSyncWorkflowDraft()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-in'], () => {
constrainedZoomIn()
handleSyncWorkflowDraft()
}),
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.download-import-log'], () => {
collaborationManager.downloadGraphImportLog()
}),
], [
constrainedZoomIn,
constrainedZoomOut,
fitView,
handleCopy,
handleEdgeDelete,
handleHistoryBack,
handleHistoryForward,
handleLayout,
handleModeComment,
handleModeHand,
handleModePointer,
handleNodesDelete,
handleNodesDuplicate,
handleNodesPaste,
handleSyncWorkflowDraft,
handleToggleMaximizeCanvas,
historyShortcutsEnabled,
isCommentModeAvailable,
showDebugAndPreviewPanel,
zoomTo,
])
useHotkeys(hotkeys, workflowHotkeyOptions)
useEffect(() => {
if (isShiftHeld) {
if (shiftDimmedRef.current)
return
if (isEventTargetInputArea(document.activeElement as HTMLElement))
return
shiftDimmedRef.current = true
dimOtherNodes()
return
}
if (!shiftDimmedRef.current)
return
shiftDimmedRef.current = false
undimAllNodes()
}, [dimOtherNodes, isShiftHeld, undimAllNodes])
useEffect(() => {
return () => {
if (shiftDimmedRef.current)
undimAllNodesRef.current()
}
}, [])
useEffect(() => {
return subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, handleZenToggle)
}, [handleZenToggle])
}

View File

@ -1,12 +1,47 @@
import type { StateCreator } from 'zustand'
import type { WorkflowHistoryEventT } from '../../hooks/use-workflow-history'
import type { Edge, Node } from '../../types'
import type {
HistoryWorkflowData,
} from '@/app/components/workflow/types'
import type {
VersionHistory,
} from '@/types/workflow'
import isDeepEqual from 'fast-deep-equal'
export type WorkflowHistoryEventMeta = {
nodeId?: string
nodeTitle?: string
}
export type WorkflowHistoryState = {
nodes: Node[]
edges: Edge[]
workflowHistoryEvent: WorkflowHistoryEventT | undefined
workflowHistoryEventMeta?: WorkflowHistoryEventMeta
}
export type WorkflowHistoryTemporalState = Pick<HistorySliceShape, 'workflowHistory'>
export const getWorkflowHistoryTemporalState = (state: HistorySliceShape): WorkflowHistoryTemporalState => ({
workflowHistory: state.workflowHistory,
})
export const isWorkflowHistoryTemporalStateEqual = (
pastState: WorkflowHistoryTemporalState,
currentState: WorkflowHistoryTemporalState,
) => {
if (pastState.workflowHistory === currentState.workflowHistory)
return true
return isDeepEqual(pastState.workflowHistory, currentState.workflowHistory)
}
export type HistorySliceShape = {
workflowHistory: WorkflowHistoryState
setWorkflowHistory: (workflowHistory: WorkflowHistoryState) => void
historyShortcutsEnabled: boolean
setHistoryShortcutsEnabled: (enabled: boolean) => void
historyWorkflowData?: HistoryWorkflowData
setHistoryWorkflowData: (historyWorkflowData?: HistoryWorkflowData) => void
showRunHistory: boolean
@ -16,6 +51,15 @@ export type HistorySliceShape = {
}
export const createHistorySlice: StateCreator<HistorySliceShape> = set => ({
workflowHistory: {
nodes: [],
edges: [],
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
},
setWorkflowHistory: workflowHistory => set(() => ({ workflowHistory })),
historyShortcutsEnabled: true,
setHistoryShortcutsEnabled: historyShortcutsEnabled => set(() => ({ historyShortcutsEnabled })),
historyWorkflowData: undefined,
setHistoryWorkflowData: historyWorkflowData => set(() => ({ historyWorkflowData })),
showRunHistory: false,

View File

@ -1,5 +1,7 @@
import type { TemporalState } from 'zundo'
import type {
StateCreator,
StoreApi,
} from 'zustand'
import type { ChatVariableSliceShape } from './chat-variable-slice'
import type { CommentSliceShape } from './comment-slice'
@ -7,7 +9,7 @@ import type { InspectVarsSliceShape } from './debug/inspect-vars-slice'
import type { EnvVariableSliceShape } from './env-variable-slice'
import type { FormSliceShape } from './form-slice'
import type { HelpLineSliceShape } from './help-line-slice'
import type { HistorySliceShape } from './history-slice'
import type { HistorySliceShape, WorkflowHistoryTemporalState } from './history-slice'
import type { LayoutSliceShape } from './layout-slice'
import type { NodeSliceShape } from './node-slice'
import type { PanelSliceShape } from './panel-slice'
@ -17,7 +19,8 @@ import type { WorkflowDraftSliceShape } from './workflow-draft-slice'
import type { WorkflowSliceShape } from './workflow-slice'
import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store'
import type { WorkflowSliceShape as WorkflowAppSliceShape } from '@/app/components/workflow-app/store/workflow/workflow-slice'
import { useContext } from 'react'
import { use } from 'react'
import { temporal } from 'zundo'
import {
useStore as useZustandStore,
} from 'zustand'
@ -29,7 +32,11 @@ import { createInspectVarsSlice } from './debug/inspect-vars-slice'
import { createEnvVariableSlice } from './env-variable-slice'
import { createFormSlice } from './form-slice'
import { createHelpLineSlice } from './help-line-slice'
import { createHistorySlice } from './history-slice'
import {
createHistorySlice,
getWorkflowHistoryTemporalState,
isWorkflowHistoryTemporalStateEqual,
} from './history-slice'
import { createLayoutSlice } from './layout-slice'
import { createNodeSlice } from './node-slice'
@ -60,6 +67,10 @@ export type Shape
& LayoutSliceShape
& SliceFromInjection
type WorkflowStoreApi = StoreApi<Shape> & {
temporal: StoreApi<TemporalState<WorkflowHistoryTemporalState>>
}
export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection>
type CreateWorkflowStoreParams = {
@ -69,27 +80,35 @@ type CreateWorkflowStoreParams = {
export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
const { injectWorkflowStoreSliceFn } = params || {}
return createStore<Shape>((...args) => ({
...createChatVariableSlice(...args),
...createEnvVariableSlice(...args),
...createFormSlice(...args),
...createHelpLineSlice(...args),
...createHistorySlice(...args),
...createNodeSlice(...args),
...createPanelSlice(...args),
...createCommentSlice(...args),
...createToolSlice(...args),
...createVersionSlice(...args),
...createWorkflowDraftSlice(...args),
...createWorkflowSlice(...args),
...createInspectVarsSlice(...args),
...createLayoutSlice(...args),
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
}))
return createStore<Shape>()(
temporal<Shape, [], [], WorkflowHistoryTemporalState>(
(...args) => ({
...createChatVariableSlice(...args),
...createEnvVariableSlice(...args),
...createFormSlice(...args),
...createHelpLineSlice(...args),
...createHistorySlice(...args),
...createNodeSlice(...args),
...createPanelSlice(...args),
...createCommentSlice(...args),
...createToolSlice(...args),
...createVersionSlice(...args),
...createWorkflowDraftSlice(...args),
...createWorkflowSlice(...args),
...createInspectVarsSlice(...args),
...createLayoutSlice(...args),
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
}),
{
partialize: getWorkflowHistoryTemporalState,
equality: isWorkflowHistoryTemporalStateEqual,
},
),
) as WorkflowStoreApi
}
export function useStore<T>(selector: (state: Shape) => T): T {
const store = useContext(WorkflowContext)
const store = use(WorkflowContext)
if (!store)
throw new Error('Missing WorkflowContext.Provider in the tree')
@ -97,5 +116,5 @@ export function useStore<T>(selector: (state: Shape) => T): T {
}
export const useWorkflowStore = () => {
return useContext(WorkflowContext)!
return use(WorkflowContext)!
}

View File

@ -0,0 +1,99 @@
import type { TemporalState } from 'zundo'
import type {
WorkflowHistoryState,
} from './store/workflow/history-slice'
import type { Edge, Node } from './types'
import { use, useMemo } from 'react'
import { WorkflowContext } from './context'
type WorkflowHistoryTemporalSnapshot = {
workflowHistory: WorkflowHistoryState
}
type WorkflowHistoryTemporalStore = {
getState: () => TemporalState<WorkflowHistoryState>
subscribe: (listener: (state: TemporalState<WorkflowHistoryState>) => void) => () => void
}
type WorkflowHistoryStore = {
getState: () => WorkflowHistoryState
setState: (state: WorkflowHistoryState) => void
subscribe: (listener: (state: WorkflowHistoryState) => void) => () => void
temporal: WorkflowHistoryTemporalStore
}
const sanitizeWorkflowHistory = (state: WorkflowHistoryState): WorkflowHistoryState => ({
workflowHistoryEvent: state.workflowHistoryEvent,
workflowHistoryEventMeta: state.workflowHistoryEventMeta,
nodes: state.nodes.map((node: Node) => ({
...node,
data: {
...node.data,
selected: false,
},
})),
edges: state.edges.map((edge: Edge) => ({
...edge,
selected: false,
}) as Edge),
})
const toHistoryState = (
state?: Partial<WorkflowHistoryTemporalSnapshot>,
): Partial<WorkflowHistoryState> => {
return state?.workflowHistory ?? {}
}
const toTemporalState = (
temporalState: TemporalState<WorkflowHistoryTemporalSnapshot>,
): TemporalState<WorkflowHistoryState> => ({
pastStates: temporalState.pastStates.map(toHistoryState),
futureStates: temporalState.futureStates.map(toHistoryState),
undo: temporalState.undo,
redo: temporalState.redo,
clear: temporalState.clear,
isTracking: temporalState.isTracking,
pause: temporalState.pause,
resume: temporalState.resume,
setOnSave: onSave => temporalState.setOnSave(
onSave
? (pastState, currentState) => {
onSave(
toHistoryState(pastState) as WorkflowHistoryState,
toHistoryState(currentState) as WorkflowHistoryState,
)
}
: undefined,
),
})
export function useWorkflowHistoryStore() {
const workflowStore = use(WorkflowContext)
if (!workflowStore)
throw new Error('Missing WorkflowContext.Provider in the tree')
return {
store: useMemo(
() => ({
getState: () => workflowStore.getState().workflowHistory,
setState: (state: WorkflowHistoryState) => {
workflowStore.getState().setWorkflowHistory(sanitizeWorkflowHistory(state))
},
subscribe: (listener: (state: WorkflowHistoryState) => void) => {
return workflowStore.subscribe((state, previousState) => {
if (state.workflowHistory !== previousState.workflowHistory)
listener(state.workflowHistory)
})
},
temporal: {
getState: () => toTemporalState(workflowStore.temporal.getState()),
subscribe: listener => workflowStore.temporal.subscribe((state) => {
listener(toTemporalState(state))
}),
},
}) satisfies WorkflowHistoryStore,
[workflowStore],
),
}
}

View File

@ -1,132 +0,0 @@
import type { ReactNode } from 'react'
import type { TemporalState } from 'zundo'
import type { StoreApi } from 'zustand'
import type { WorkflowHistoryEventT } from './hooks'
import type { Edge, Node } from './types'
import { noop } from 'es-toolkit/function'
import isDeepEqual from 'fast-deep-equal'
import { createContext, useContext, useMemo, useState } from 'react'
import { temporal } from 'zundo'
import { create } from 'zustand'
export const WorkflowHistoryStoreContext = createContext<WorkflowHistoryStoreContextType>({ store: null, shortcutsEnabled: true, setShortcutsEnabled: noop })
const Provider = WorkflowHistoryStoreContext.Provider
export function WorkflowHistoryProvider({
nodes,
edges,
children,
}: WorkflowWithHistoryProviderProps) {
const [shortcutsEnabled, setShortcutsEnabled] = useState(true)
const [store] = useState(() =>
createStore({
nodes,
edges,
}),
)
const contextValue = {
store,
shortcutsEnabled,
setShortcutsEnabled,
}
return (
<Provider value={contextValue}>
{children}
</Provider>
)
}
export function useWorkflowHistoryStore() {
const {
store,
shortcutsEnabled,
setShortcutsEnabled,
} = useContext(WorkflowHistoryStoreContext)
if (store === null)
throw new Error('useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider')
return {
store: useMemo(
() => ({
getState: store.getState,
setState: (state: WorkflowHistoryState) => {
store.setState({
workflowHistoryEvent: state.workflowHistoryEvent,
workflowHistoryEventMeta: state.workflowHistoryEventMeta,
nodes: state.nodes.map((node: Node) => ({ ...node, data: { ...node.data, selected: false } })),
edges: state.edges.map((edge: Edge) => ({ ...edge, selected: false }) as Edge),
})
},
subscribe: store.subscribe,
temporal: store.temporal,
}),
[store],
),
shortcutsEnabled,
setShortcutsEnabled,
}
}
function createStore({
nodes: storeNodes,
edges: storeEdges,
}: {
nodes: Node[]
edges: Edge[]
}): WorkflowHistoryStoreApi {
const store = create(temporal<WorkflowHistoryState>(
(set, get) => {
return {
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
nodes: storeNodes,
edges: storeEdges,
getNodes: () => get().nodes,
setNodes: (nodes: Node[]) => set({ nodes }),
setEdges: (edges: Edge[]) => set({ edges }),
}
},
{
equality: (pastState, currentState) =>
isDeepEqual(pastState, currentState),
},
),
)
return store
}
type WorkflowHistoryStore = {
nodes: Node[]
edges: Edge[]
workflowHistoryEvent: WorkflowHistoryEventT | undefined
workflowHistoryEventMeta?: WorkflowHistoryEventMeta
}
type WorkflowHistoryActions = {
setNodes?: (nodes: Node[]) => void
setEdges?: (edges: Edge[]) => void
}
export type WorkflowHistoryState = WorkflowHistoryStore & WorkflowHistoryActions
type WorkflowHistoryStoreContextType = {
store: ReturnType<typeof createStore> | null
shortcutsEnabled: boolean
setShortcutsEnabled: (enabled: boolean) => void
}
export type WorkflowHistoryStoreApi = StoreApi<WorkflowHistoryState> & { temporal: StoreApi<TemporalState<WorkflowHistoryState>> }
type WorkflowWithHistoryProviderProps = {
nodes: Node[]
edges: Edge[]
children: ReactNode
}
export type WorkflowHistoryEventMeta = {
nodeId?: string
nodeTitle?: string
}

View File

@ -18,7 +18,7 @@ import {
useViewport,
} from 'reactflow'
import TipPopup from '@/app/components/workflow/operator/tip-popup'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
enum ZoomType {
zoomToFit = 'zoomToFit',
@ -104,9 +104,12 @@ const ZoomInOut: FC = () => {
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
<TipPopup
title={t('operator.zoomOut', { ns: 'workflow' })}
shortcuts={['ctrl', '-']}
shortcut="workflow.zoom-out"
>
<div
<button
type="button"
aria-label={t('operator.zoomOut', { ns: 'workflow' })}
disabled={zoom <= 0.25}
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
onClick={(e) => {
if (zoom <= 0.25)
@ -117,7 +120,7 @@ const ZoomInOut: FC = () => {
}}
>
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
</div>
</button>
</TipPopup>
<DropdownMenu
open={open}
@ -149,13 +152,13 @@ const ZoomInOut: FC = () => {
<span>{option.text}</span>
<div className="flex items-center space-x-0.5">
{option.key === ZoomType.zoomToFit && (
<ShortcutsName keys={['ctrl', '1']} />
<ShortcutKbd shortcut="workflow.zoom-to-fit" />
)}
{option.key === ZoomType.zoomTo50 && (
<ShortcutsName keys={['shift', '5']} />
<ShortcutKbd shortcut="workflow.zoom-to-50" />
)}
{option.key === ZoomType.zoomTo100 && (
<ShortcutsName keys={['shift', '1']} />
<ShortcutKbd shortcut="workflow.zoom-to-100" />
)}
</div>
</DropdownMenuItem>
@ -168,9 +171,12 @@ const ZoomInOut: FC = () => {
</DropdownMenu>
<TipPopup
title={t('operator.zoomIn', { ns: 'workflow' })}
shortcuts={['ctrl', '+']}
shortcut="workflow.zoom-in"
>
<div
<button
type="button"
aria-label={t('operator.zoomIn', { ns: 'workflow' })}
disabled={zoom >= 2}
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
onClick={(e) => {
if (zoom >= 2)
@ -181,7 +187,7 @@ const ZoomInOut: FC = () => {
}}
>
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
</div>
</button>
</TipPopup>
</div>
</div>

View File

@ -0,0 +1,138 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zConsoleSsoOAuth2LoginResponse,
zConsoleSsoOidcLoginResponse,
zConsoleSsoSamlLoginResponse,
zWebAppAuthGetGroupSubjectsQuery,
zWebAppAuthGetGroupSubjectsResponse,
zWebAppAuthGetWebAppAccessModeQuery,
zWebAppAuthGetWebAppAccessModeResponse,
zWebAppAuthGetWebAppWhitelistSubjectsQuery,
zWebAppAuthGetWebAppWhitelistSubjectsResponse,
zWebAppAuthIsUserAllowedToAccessWebAppQuery,
zWebAppAuthIsUserAllowedToAccessWebAppResponse,
zWebAppAuthSearchForWhilteListCandidatesQuery,
zWebAppAuthSearchForWhilteListCandidatesResponse,
zWebAppAuthUpdateWebAppWhitelistSubjectsBody,
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
} from './zod.gen'
export const oAuth2Login = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleSSO_OAuth2Login',
path: '/enterprise/sso/oauth2/login',
tags: ['ConsoleSSO'],
})
.output(zConsoleSsoOAuth2LoginResponse)
export const oidcLogin = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleSSO_OIDCLogin',
path: '/enterprise/sso/oidc/login',
tags: ['ConsoleSSO'],
})
.output(zConsoleSsoOidcLoginResponse)
export const samlLogin = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleSSO_SAMLLogin',
path: '/enterprise/sso/saml/login',
tags: ['ConsoleSSO'],
})
.output(zConsoleSsoSamlLoginResponse)
export const consoleSso = {
oAuth2Login,
oidcLogin,
samlLogin,
}
export const getWebAppAccessMode = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_GetWebAppAccessMode',
path: '/enterprise/webapp/app/access-mode',
tags: ['WebAppAuth'],
})
.input(z.object({ query: zWebAppAuthGetWebAppAccessModeQuery.optional() }))
.output(zWebAppAuthGetWebAppAccessModeResponse)
export const updateWebAppWhitelistSubjects = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'WebAppAuth_UpdateWebAppWhitelistSubjects',
path: '/enterprise/webapp/app/access-mode',
tags: ['WebAppAuth'],
})
.input(z.object({ body: zWebAppAuthUpdateWebAppWhitelistSubjectsBody }))
.output(zWebAppAuthUpdateWebAppWhitelistSubjectsResponse)
export const searchForWhilteListCandidates = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_SearchForWhilteListCandidates',
path: '/enterprise/webapp/app/subject/search',
tags: ['WebAppAuth'],
})
.input(z.object({ query: zWebAppAuthSearchForWhilteListCandidatesQuery.optional() }))
.output(zWebAppAuthSearchForWhilteListCandidatesResponse)
export const getWebAppWhitelistSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_GetWebAppWhitelistSubjects',
path: '/enterprise/webapp/app/subjects',
tags: ['WebAppAuth'],
})
.input(z.object({ query: zWebAppAuthGetWebAppWhitelistSubjectsQuery.optional() }))
.output(zWebAppAuthGetWebAppWhitelistSubjectsResponse)
export const getGroupSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_GetGroupSubjects',
path: '/enterprise/webapp/group/subjects',
tags: ['WebAppAuth'],
})
.input(z.object({ query: zWebAppAuthGetGroupSubjectsQuery.optional() }))
.output(zWebAppAuthGetGroupSubjectsResponse)
export const isUserAllowedToAccessWebApp = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_IsUserAllowedToAccessWebApp',
path: '/enterprise/webapp/permission',
tags: ['WebAppAuth'],
})
.input(z.object({ query: zWebAppAuthIsUserAllowedToAccessWebAppQuery.optional() }))
.output(zWebAppAuthIsUserAllowedToAccessWebAppResponse)
export const webAppAuth = {
getWebAppAccessMode,
updateWebAppWhitelistSubjects,
searchForWhilteListCandidates,
getWebAppWhitelistSubjects,
getGroupSubjects,
isUserAllowedToAccessWebApp,
}
export const contract = {
consoleSso,
webAppAuth,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,7 @@ import {
workflowDraftUpdateFeaturesContract,
} from './console/workflow'
import { workflowCommentContracts } from './console/workflow-comment'
import { contract as enterpriseContract } from './generated/enterprise/orpc.gen'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace'
export const marketplaceRouterContract = {
@ -54,6 +55,7 @@ export const marketplaceRouterContract = {
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
export const consoleRouterContract = {
enterprise: enterpriseContract,
account: {
avatar: accountAvatarContract,
},

View File

@ -8,9 +8,11 @@ const config: KnipConfig = {
'scripts/**/*.{js,ts,mjs}',
'bin/**/*.{js,ts,mjs}',
'tsslint.config.ts',
'openapi-ts.*.config.ts',
],
ignore: [
'public/**',
'contract/generated/**',
],
ignoreBinaries: [
'only-allow',

View File

@ -0,0 +1,119 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@hey-api/openapi-ts'
import yaml from 'js-yaml'
type JsonObject = Record<string, unknown>
type OpenApiDocument = JsonObject & {
paths?: Record<string, unknown>
}
type ContractOperation = {
id: string
operationId?: string
tags?: readonly string[]
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
: path.resolve(currentDir, '../../dify-enterprise/server')
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
const stripConsoleApiPrefix = (routePath: string) => {
if (isConsoleApiPath(routePath))
return routePath.replace('/console/api', '')
return routePath
}
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^pagination\./, '')
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
const tagPrefixPattern = tag ? new RegExp(`^${tag}[._/-]`) : undefined
const name = tagPrefixPattern ? operationId.replace(tagPrefixPattern, '') : operationId
const segments = name.split(/[._/-]+/).filter(Boolean)
return segments.length > 0 ? segments : [operationId]
}
const contractPathSegments = (operation: ContractOperation) => {
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
}
const normalizeEnterpriseOpenApi = () => {
const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi))
throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`)
const document = openApi as OpenApiDocument
const paths = document.paths ?? {}
document.paths = Object.fromEntries(
Object.entries(paths)
.filter(([routePath]) => isConsoleApiPath(routePath))
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
)
return document
}
export default defineConfig({
input: normalizeEnterpriseOpenApi(),
output: {
entryFile: false,
path: 'contract/generated/enterprise',
fileName: {
suffix: '.gen',
},
postProcess: [
{
command: 'vp',
args: ['fmt', '{{path}}'],
},
{
command: 'eslint',
args: ['--fix', '{{path}}'],
},
],
},
parser: {
transforms: {
schemaName: stripSchemaNamePrefix,
},
},
plugins: [
{
name: '@hey-api/typescript',
comments: false,
},
'zod',
{
name: 'orpc',
contracts: {
strategy: 'single',
contractName: {
name: '{{name}}',
casing: 'camelCase',
},
nesting: contractPathSegments,
segmentName: {
name: '{{name}}',
casing: 'camelCase',
},
},
validator: 'zod',
},
],
})

View File

@ -31,6 +31,7 @@
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
"dev:vinext": "vinext dev",
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"gen-icons": "pnpm --filter @dify/iconify-collections generate && node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
"i18n:check": "tsx ./scripts/check-i18n.js",
"knip": "knip",
@ -75,6 +76,7 @@
"@t3-oss/env-nextjs": "catalog:",
"@tailwindcss/typography": "catalog:",
"@tanstack/react-form": "catalog:",
"@tanstack/react-hotkeys": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "catalog:",
"abcjs": "catalog:",
@ -158,6 +160,7 @@
"@dify/tsconfig": "workspace:*",
"@egoist/tailwindcss-icons": "catalog:",
"@eslint-react/eslint-plugin": "catalog:",
"@hey-api/openapi-ts": "catalog:",
"@hono/node-server": "catalog:",
"@iconify-json/heroicons": "catalog:",
"@iconify-json/ri": "catalog:",

View File

@ -2,7 +2,7 @@ import type { Plugin } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import { codeInspectorPlugin } from 'code-inspector-plugin'
import { injectClientSnippet, normalizeViteModuleId } from './utils'
import { injectClientSnippet, normalizeViteModuleId } from './utils.ts'
type CodeInspectorPluginOptions = {
injectTarget: string

View File

@ -1,6 +1,6 @@
import type { Plugin } from 'vite'
import fs from 'node:fs'
import { injectClientSnippet, normalizeViteModuleId } from './utils'
import { injectClientSnippet, normalizeViteModuleId } from './utils.ts'
type CustomI18nHmrPluginOptions = {
injectTarget: string

View File

@ -1,6 +1,6 @@
import type { Plugin } from 'vite'
import path from 'node:path'
import { normalizeViteModuleId } from './utils'
import { normalizeViteModuleId } from './utils.ts'
type NextStaticImageTestPluginOptions = {
projectRoot: string

View File

@ -15,6 +15,7 @@
"vitest/globals",
"node"
],
"allowImportingTsExtensions": true,
"allowJs": true
},
"include": [

View File

@ -4,10 +4,10 @@ import react from '@vitejs/plugin-react'
import vinext from 'vinext'
import Inspect from 'vite-plugin-inspect'
import { defineConfig } from 'vite-plus'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
import { getRootClientInjectTarget } from './plugins/vite/inject-target'
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector.ts'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr.ts'
import { getRootClientInjectTarget } from './plugins/vite/inject-target.ts'
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test.ts'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
const isCI = !!process.env.CI