diff --git a/api/core/plugin/utils/http_parser.py b/api/core/plugin/utils/http_parser.py index ce943929be..af0ff10bfb 100644 --- a/api/core/plugin/utils/http_parser.py +++ b/api/core/plugin/utils/http_parser.py @@ -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 diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 1461ae6a47..2d85a038c4 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -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, diff --git a/api/tests/unit_tests/core/plugin/utils/test_http_parser.py b/api/tests/unit_tests/core/plugin/utils/test_http_parser.py index 71144695bc..e0419d3266 100644 --- a/api/tests/unit_tests/core/plugin/utils/test_http_parser.py +++ b/api/tests/unit_tests/core/plugin/utils/test_http_parser.py @@ -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( diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py index 12aec6edf2..ba1e74f3e0 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py @@ -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}' diff --git a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py index da68d4f997..387f508154 100644 --- a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py +++ b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py @@ -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" diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index b4c16bd72e..334cdab9a1 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -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"), [ diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7b24f216aa..2eaefd9436 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76e3a023f1..9529c1aee3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bba9b50682..b0c007ee4d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/vite.config.ts b/vite.config.ts index aebcaf8f73..e8f94d964a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,8 @@ export default defineConfig({ staged: { '*': 'eslint --fix --pass-on-unpruned-suppressions', }, + fmt: { + singleQuote: true, + semi: false, + }, }) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index e76bbb0728..3c272f687e 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -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 = ({ modelAndParameter={modelAndParameter} /> - }> - - - - + + + + )} + /> {!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title} -
+
{ !collapse && ( diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index a09e25f6e9..d14de7d58b 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -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(( + + )) + + 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() + }) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 503af4077d..10c371916b 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -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 ( { render() - // 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', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index 43b5fcc71a..a77ba87ac6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -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} > - }> - - + + + + )} + /> { 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() }) }) diff --git a/web/app/components/goto-anything/actions/commands/zen.tsx b/web/app/components/goto-anything/actions/commands/zen.tsx index 1645e40fd9..f4e0bec7a9 100644 --- a/web/app/components/goto-anything/actions/commands/zen.tsx +++ b/web/app/components/goto-anything/actions/commands/zen.tsx @@ -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 -// 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) } /** diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index bcd3acb6b2..ccdc13a8a0 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -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 ( - }> - - - - + + + + )} + /> handleAction('setDefault')}> diff --git a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx index 5767409dbe..a3ae111f89 100644 --- a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx @@ -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) diff --git a/web/app/components/workflow/__tests__/index.spec.tsx b/web/app/components/workflow/__tests__/index.spec.tsx index 77b61e54e7..5e99baaaad 100644 --- a/web/app/components/workflow/__tests__/index.spec.tsx +++ b/web/app/components/workflow/__tests__/index.spec.tsx @@ -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 (
{`history:${store.getState().nodes.length}`} - {` shortcuts:${String(shortcutsEnabled)}`} {` datasets:${datasetCount}`} {` reactflow:${String(!!reactFlowStore)}`}
@@ -52,16 +52,18 @@ const ContextConsumer = () => { describe('WorkflowWithDefaultContext', () => { it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => { render( - - - , + + + + + , ) expect( - screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'), + screen.getByText('history:1 datasets:0 reactflow:true'), ).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 08ac245172..2c9c457245 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -145,8 +145,8 @@ describe('PanelContextmenu', () => { const { container } = render() 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', diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 47545baca8..6dc8be84b0 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -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 ? ( -
- - +
+ {title &&
{title}
} + {desc &&
{desc}
} + +
) : 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, diff --git a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx index bc2495748a..3172cc401e 100644 --- a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx @@ -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 ( - + return ({ children }: { children: React.ReactNode }) => ( + + {children} + ) } -describe('WorkflowHistoryProvider', () => { - it('provides workflow history state and shortcut toggles', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - 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 }) => ( - - {children} - - ) + 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', ) }) }) diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx index 549a055c1e..b88600331a 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -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 = RenderHookResult & 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( 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>( // 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( - (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, } } diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 34bdf54715..2160008c6b 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -74,11 +74,16 @@ const OperationDropdown: FC = ({ open={open} onOpenChange={setOpen} > - }> - - - - + + + + )} + /> export const WorkflowContext = createContext(null) diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx index 2b7f13190a..e4f8ef95e0 100644 --- a/web/app/components/workflow/edge-contextmenu.tsx +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -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)} > {t('common:operation.delete')} - + diff --git a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx index 02b645e079..245b516cd3 100644 --- a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -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, })) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 8e93612dc0..ebf1af5a3d 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -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, children) + if (render) { + return React.cloneElement( + render, + { onClick: () => setOpen(!open) } as Record, + children ?? render.props.children, + ) + } return }, diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx index 2ba950fd68..30c1ee9c0c 100644 --- a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx @@ -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 void | Promise>()) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ @@ -9,17 +10,22 @@ vi.mock('@/hooks/use-theme', () => ({ }), })) -vi.mock('../../utils', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - getKeyboardKeyCodeBySystem: () => 'ctrl', - } -}) +vi.mock('../../shortcuts/use-workflow-hotkeys', () => ({ + useWorkflowShortcut: (id: string, callback: () => void | Promise) => { + 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 }) =>
{children}
, +})) 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() - 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) }) diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index 923f6f0330..108d1f98da 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -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(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} > - + {isListening ? t('common.listening', { ns: 'workflow' }) : t('common.running', { ns: 'workflow' })} ) @@ -127,16 +122,17 @@ const RunMode = ({ options={dynamicOptions} onSelect={handleTriggerSelect} > -
- + {text ?? t('common.run', { ns: 'workflow' })} - -
+ + ) } diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index 9f14190c54..dc2b5acd03 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -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 = ({ {option.name}
{shortcutKey && ( - + )}
) @@ -111,8 +111,8 @@ export const SingleOptionTrigger = ({ } return ( - + ) } diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index ceaf38592f..05e26918e8 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -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(({ open={open} onOpenChange={setOpen} > - }> - {children} - + {isValidElement(children) + ? ( + + ) + : ( + + {children} + + )} = ({ handleUndo, handleRedo }) => { return (
- + - +
) }) @@ -39,27 +39,30 @@ const VersionHistoryButton: FC = ({ await onClick?.() }, [onClick]) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => { - e.preventDefault() + useWorkflowShortcut('workflow.version-history', () => { handleViewVersionHistory() - }, { exactMatch: true, useCapture: true }) + }) return ( - } - 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" - > - )} - onClick={handleViewVersionHistory} + /> + - - + + ) } diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index 036f27d38d..849fff9266 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -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 ( ( { if (nodesReadOnly) @@ -148,49 +150,56 @@ const ViewWorkflowHistory = () => { setOpen(nextOpen) }} > - - { - if (nodesReadOnly) - return - setCurrentLogItem() - setShowMessageLogModal(false) - }} + { + if (nodesReadOnly) + return + setCurrentLogItem() + setShowMessageLogModal(false) + }} + > + - -
- )} - /> - + + + + + + )} + />
{t('changeHistory.title', { ns: 'workflow' })}
-
+ + + )} onClick={() => { setCurrentLogItem() setShowMessageLogModal(false) - setOpen(false) }} - > - -
+ />
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(() => []) 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() + }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx index 54917d009c..61ca72b13a 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx @@ -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() diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index d3e9a38bff..e0e24ef994 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -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' diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts deleted file mode 100644 index e4100908ff..0000000000 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ /dev/null @@ -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]) -} diff --git a/web/app/components/workflow/hooks/use-workflow-history.ts b/web/app/components/workflow/hooks/use-workflow-history.ts index 7feaec9709..6222102d5b 100644 --- a/web/app/components/workflow/hooks/use-workflow-history.ts +++ b/web/app/components/workflow/hooks/use-workflow-history.ts @@ -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, diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index c535196691..eff84ae01a 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -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 = 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 ( - {children} - + ) } diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx index 62cf571ae0..b9d57eb5a8 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx @@ -35,10 +35,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
{children}
), - DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => { + DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: React.ReactElement<{ children?: ReactNode }> }) => { const { open, setOpen } = useDropdownMenuContext() - if (render) - return
setOpen(!open)}>{children}
+ if (render) { + return React.cloneElement( + render, + { onClick: () => setOpen(!open) } as Record, + children ?? render.props.children, + ) + } return }, @@ -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 }) => ( - ), diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index 2dd45bbe3c..b6681695fd 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -89,11 +89,13 @@ const Operator = ({ open={open} onOpenChange={onOpenChange} > - }> - - + + + + )} + /> { canRunBySingle(data.type, isChildNode) && ( -
{ @@ -80,7 +81,7 @@ const PanelOperatorPopup = ({ }} > {t('panel.runThisStep', { ns: 'workflow' })} -
+ ) } { @@ -104,26 +105,28 @@ const PanelOperatorPopup = ({ !nodeMetaData.isSingleton && ( <>
-
{ onClosePopup() handleNodesCopy(id) }} > {t('common.copy', { ns: 'workflow' })} - -
-
+ +
+ +
@@ -133,16 +136,17 @@ const PanelOperatorPopup = ({ !nodeMetaData.isUndeletable && ( <>
-
handleNodeDelete(id)} > {t('operation.delete', { ns: 'common' })} - -
+ +
diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx index 5b6811bc95..7097f60ced 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx @@ -43,7 +43,7 @@ const Field: FC = ({ disabled={depth !== MAX_DEPTH + 1} render={(
!readonly && onSelect?.([...valueSelector, name])} >
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 38fef9016d..420888cb13 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -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 = ({ 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 = ({ = ({
)}
- +
+ +
= ({ }) => { 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 = ({ onClick={onConfirm} > {t('operation.confirm', { ns: 'common' })} - +
) diff --git a/web/app/components/workflow/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/__tests__/index.spec.tsx index 1bfa14ffb1..7b20c0de08 100644 --- a/web/app/components/workflow/note-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/index.spec.tsx @@ -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 => ({ title: '', desc: '', diff --git a/web/app/components/workflow/note-node/index.tsx b/web/app/components/workflow/note-node/index.tsx index fa69f05841..321df4cb09 100644 --- a/web/app/components/workflow/note-node/index.tsx +++ b/web/app/components/workflow/note-node/index.tsx @@ -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) => { const { t } = useTranslation() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) + const setHistoryShortcutsEnabled = useStore(s => s.setHistoryShortcutsEnabled) const ref = useRef(null) const theme = data.theme const { @@ -54,8 +54,6 @@ const NoteNode = ({ handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } }) }, ref) - const { setShortcutsEnabled } = useWorkflowHistoryStore() - return (
diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx index b675f57849..516eb5bcd9 100644 --- a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx @@ -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) }) }) diff --git a/web/app/components/workflow/note-node/note-editor/editor.tsx b/web/app/components/workflow/note-node/note-editor/editor.tsx index ab2c3df0c4..7af1b0ddeb 100644 --- a/web/app/components/workflow/note-node/note-editor/editor.tsx +++ b/web/app/components/workflow/note-node/note-editor/editor.tsx @@ -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={(
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" /> diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx index bfbfe317c7..fdc53a7d54 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx @@ -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' })} - + {t('common.duplicate', { ns: 'workflow' })} - +
@@ -107,7 +107,7 @@ const Operator = ({ }} > {t('operation.delete', { ns: 'common' })} - + diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index 07cec0360e..0ef4745e3e 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -47,7 +47,7 @@ const Control = () => { } = useNodesReadOnly() const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize() - const addNote = (e: MouseEvent) => { + const addNote = (e: MouseEvent) => { if (getNodesReadOnly()) return @@ -59,19 +59,25 @@ const Control = () => {
-
- -
+ +
- -
+
+ +
- -
+
+ +
{isCommentModeAvailable && ( - -
+
+ +
)} - -
+
+ +
- -
+
+ {maximizeCanvas && } + {!maximizeCanvas && } +
diff --git a/web/app/components/workflow/operator/tip-popup.tsx b/web/app/components/workflow/operator/tip-popup.tsx index 226a889359..0beb9453bd 100644 --- a/web/app/components/workflow/operator/tip-popup.tsx +++ b/web/app/components/workflow/operator/tip-popup.tsx @@ -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 ( - + +
{title} { - shortcuts && + shortcut && }
- )} - > - {children} +
) } diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx index d7f172d2d1..ea2ff76977 100644 --- a/web/app/components/workflow/operator/zoom-in-out.tsx +++ b/web/app/components/workflow/operator/zoom-in-out.tsx @@ -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 = ({
-
{ if (zoom <= 0.25) @@ -194,7 +197,7 @@ const ZoomInOut: FC = ({ }} > -
+
= ({
{option.key === ZoomType.zoomToFit && ( - + )} {option.key === ZoomType.zoomTo50 && ( - + )} {option.key === ZoomType.zoomTo100 && ( - + )}
@@ -281,9 +284,12 @@ const ZoomInOut: FC = ({
-
= 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 = ({ }} > -
+
diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index 4478839077..5bac2e2364 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -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 ( -
{t('common.addBlock', { ns: 'workflow' })} -
+ ) } @@ -68,8 +69,9 @@ const PanelContextmenu = () => { crossAxis: -4, }} /> -
{ e.stopPropagation() handleAddNote() @@ -77,11 +79,13 @@ const PanelContextmenu = () => { }} > {t('nodes.note.addNote', { ns: 'workflow' })} -
+ {isCommentModeAvailable && ( -
{ @@ -94,24 +98,27 @@ const PanelContextmenu = () => { }} > {t('comments.actions.addComment', { ns: 'workflow' })} -
+ )} -
{ handleStartWorkflowRun() handlePaneContextmenuCancel() }} > {t('common.run', { ns: 'workflow' })} - -
+ +
-
{ @@ -122,23 +129,25 @@ const PanelContextmenu = () => { }} > {t('common.pasteHere', { ns: 'workflow' })} - -
+ +
-
exportCheck?.()} > {t('export', { ns: 'app' })} -
-
+
+
) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 26b5429df4..56a1afbba6 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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} > {t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })} - + { onClick={handleDuplicateNodes} > {t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })} - + @@ -406,7 +406,7 @@ const SelectionContextmenu = () => { onClick={handleDeleteNodes} > {t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })} - + diff --git a/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx new file mode 100644 index 0000000000..d1be1bf008 --- /dev/null +++ b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx @@ -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( + , + ) + + 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( + , + ) + + expect(container.querySelectorAll('kbd')).toHaveLength(1) + expect(screen.getByText('⌦')).toBeInTheDocument() + }) + + it('uses TanStack non-mac modifier labels', () => { + render() + + expect(screen.getByText('Ctrl')).toBeInTheDocument() + expect(screen.getByText('C')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/shortcuts/commands.ts b/web/app/components/workflow/shortcuts/commands.ts new file mode 100644 index 0000000000..99537fde26 --- /dev/null +++ b/web/app/components/workflow/shortcuts/commands.ts @@ -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) +} diff --git a/web/app/components/workflow/shortcuts/definitions.ts b/web/app/components/workflow/shortcuts/definitions.ts new file mode 100644 index 0000000000..c0566032c3 --- /dev/null +++ b/web/app/components/workflow/shortcuts/definitions.ts @@ -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 = { + '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]! +} diff --git a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx new file mode 100644 index 0000000000..8e36e08f3c --- /dev/null +++ b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx @@ -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 ( + + { + displayKeys.map((key, index) => ( + + {key} + + )) + } + + ) +} diff --git a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts new file mode 100644 index 0000000000..98b23d08e0 --- /dev/null +++ b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts @@ -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((event) => { + if (!shouldHandleCopy()) + return + + event.preventDefault() + event.stopPropagation() + handleNodesCopy() + }, [handleNodesCopy, shouldHandleCopy]) + + const handleZenToggle = useCallback(() => { + handleToggleMaximizeCanvas() + }, [handleToggleMaximizeCanvas]) + + const hotkeys = useMemo(() => [ + ...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]) +} diff --git a/web/app/components/workflow/store/workflow/history-slice.ts b/web/app/components/workflow/store/workflow/history-slice.ts index 47d1b8ad42..7f527c2c8f 100644 --- a/web/app/components/workflow/store/workflow/history-slice.ts +++ b/web/app/components/workflow/store/workflow/history-slice.ts @@ -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 + +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 = 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, diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index 4bbe117b7b..541fecce2d 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -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 & { + temporal: StoreApi> +} + export type InjectWorkflowStoreSliceFn = StateCreator type CreateWorkflowStoreParams = { @@ -69,27 +80,35 @@ type CreateWorkflowStoreParams = { export const createWorkflowStore = (params: CreateWorkflowStoreParams) => { const { injectWorkflowStoreSliceFn } = params || {} - return createStore((...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()( + temporal( + (...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(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(selector: (state: Shape) => T): T { } export const useWorkflowStore = () => { - return useContext(WorkflowContext)! + return use(WorkflowContext)! } diff --git a/web/app/components/workflow/workflow-history-store.ts b/web/app/components/workflow/workflow-history-store.ts new file mode 100644 index 0000000000..83b32ed3e9 --- /dev/null +++ b/web/app/components/workflow/workflow-history-store.ts @@ -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 + subscribe: (listener: (state: TemporalState) => 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, +): Partial => { + return state?.workflowHistory ?? {} +} + +const toTemporalState = ( + temporalState: TemporalState, +): TemporalState => ({ + 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], + ), + } +} diff --git a/web/app/components/workflow/workflow-history-store.tsx b/web/app/components/workflow/workflow-history-store.tsx deleted file mode 100644 index 97c9f2ac33..0000000000 --- a/web/app/components/workflow/workflow-history-store.tsx +++ /dev/null @@ -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({ 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 ( - - {children} - - ) -} - -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( - (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 | null - shortcutsEnabled: boolean - setShortcutsEnabled: (enabled: boolean) => void -} - -export type WorkflowHistoryStoreApi = StoreApi & { temporal: StoreApi> } - -type WorkflowWithHistoryProviderProps = { - nodes: Node[] - edges: Edge[] - children: ReactNode -} - -export type WorkflowHistoryEventMeta = { - nodeId?: string - nodeTitle?: string -} diff --git a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx index 9ea32caec3..92a96a030f 100644 --- a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx +++ b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx @@ -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 = () => {
-
{ if (zoom <= 0.25) @@ -117,7 +120,7 @@ const ZoomInOut: FC = () => { }} > -
+
{ {option.text}
{option.key === ZoomType.zoomToFit && ( - + )} {option.key === ZoomType.zoomTo50 && ( - + )} {option.key === ZoomType.zoomTo100 && ( - + )}
@@ -168,9 +171,12 @@ const ZoomInOut: FC = () => {
-
= 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 = () => { }} > -
+
diff --git a/web/contract/generated/enterprise/orpc.gen.ts b/web/contract/generated/enterprise/orpc.gen.ts new file mode 100644 index 0000000000..6b9b76470a --- /dev/null +++ b/web/contract/generated/enterprise/orpc.gen.ts @@ -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, +} diff --git a/web/contract/generated/enterprise/types.gen.ts b/web/contract/generated/enterprise/types.gen.ts new file mode 100644 index 0000000000..b747c4baa8 --- /dev/null +++ b/web/contract/generated/enterprise/types.gen.ts @@ -0,0 +1,1103 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}) +} + +export type Account = { + id?: string + email?: string + name?: string +} + +export type AccountDetail = { + account?: Account + status?: string + createdAt?: string + lastActiveAt?: string + workspaces?: Array + groups?: Array +} + +export type AccountDetailGroup = { + id?: string + name?: string +} + +export type AccountInWorkspace = { + workspaceId?: string + workspaceName?: string + role?: string +} + +export type AddGroupAppsRequest = { + id?: string + app_ids?: Array +} + +export type AuthSettingsReply = { + userSsoSettings?: SsoSettings + webSsoSettings?: SsoSettings + dashboardSsoSettings?: SsoSettings + userSsoSamlAcsUrl?: string + userSsoOidcCallbackUrl?: string + userSsoOauth2CallbackUrl?: string + webSsoSamlAcsUrl?: string + webSsoOidcCallbackUrl?: string + webSsoOauth2CallbackUrl?: string + webSsoMembersSamlAcsUrl?: string + webSsoMembersOidcCallbackUrl?: string + webSsoMembersOauth2CallbackUrl?: string + dashboardSsoSamlAcsUrl?: string + dashboardSsoOidcCallbackUrl?: string + dashboardSsoOauth2CallbackUrl?: string +} + +export type AuthSettingsReq = { + ssoType?: string + ssoSettings?: SsoSettings +} + +export type BrandingInfo = { + enabled?: boolean + applicationTitle?: string + loginPageLogo?: string + workspaceLogo?: string + favicon?: string +} + +export type CheckPasswordStatusReply = { + requirePasswordChange?: boolean + changeReason?: number + daysToExpire?: number + message?: string +} + +export type ClearDefaultWorkspaceReply = { + [key: string]: unknown +} + +export type CreateBearerTokenResponse = { + token?: string +} + +export type CreateMemberReply = { + id?: string + password?: string +} + +export type CreateMemberReq = { + name?: string + email?: string + status?: string +} + +export type CreateNewGroupsReq = { + groups?: Array +} + +export type CreateNewGroupsReqGroup = { + name?: string +} + +export type CreateNewGroupsRes = { + groups?: Array +} + +export type CreateResourceGroupRequest = { + name?: string + description?: string +} + +export type CreateSecretKeyReply = { + id?: string + name?: string + secretKey?: string + createdAt?: string + lastActive?: string +} + +export type CreateSecretKeyReq = { + name?: string +} + +export type CreateUserReply = { + id?: string + password?: string +} + +export type CreateUserReq = { + name?: string + email?: string + status?: string +} + +export type CreateWorkspaceReply = { + workspace?: Workspace +} + +export type CreateWorkspaceReq = { + name?: string + email?: string + status?: string +} + +export type CurrentUserReply = { + id?: string + name?: string + email?: string + interfaceLanguage?: string + timezone?: string +} + +export type DashboardSsooidcLoginReply = { + url?: string + state?: string +} + +export type DashboardSsoOauth2LoginReply = { + url?: string + state?: string +} + +export type DashboardSsosamlLoginReply = { + url?: string +} + +export type DeleteGroupsRes = { + message?: string +} + +export type DeleteMemberReply = { + account?: Account +} + +export type DeleteSecretKeyReply = { + message?: string +} + +export type DeleteUserReply = { + account?: Account +} + +export type DeleteWorkspaceReply = { + [key: string]: unknown +} + +export type EndpointReply = { + mode?: number + metricsEndpoint?: OtelExporterEndpoint + tracesEndpoint?: OtelExporterEndpoint +} + +export type EnterpriseSystemUserSettingReply = { + ssoEnforcedForSignin?: boolean + ssoEnforcedForSigninProtocol?: string + enableEmailPasswordLogin?: boolean +} + +export type GetBearerTokenResponse = { + maskedToken?: string +} + +export type GetClusterInfoReply = { + mode?: string + clusterId?: string + verifyMode?: string +} + +export type GetDefaultWorkspaceReply = { + workspaceId?: string + workspace?: Workspace +} + +export type GetGroupSubjectsRes = { + subjects?: Array +} + +export type GetGroupsRes = { + groups?: Array +} + +export type GetJoinedGroupsRes = { + groups?: Array +} + +export type GetLicenseReply = { + license?: LicenseInfo +} + +export type GetLicenseStatusReply = { + status?: string +} + +export type GetMfaInfoReply = { + userEnabled?: boolean + userSetup?: boolean + globalEnabled?: boolean +} + +export type GetMemberReply = { + account?: AccountDetail +} + +export type GetUserReply = { + account?: AccountDetail +} + +export type GetWebAppAccessModeRes = { + accessMode?: string +} + +export type GetWebAppAuthInfoRes = { + allowSso?: boolean + allowEmailCodeLogin?: boolean + allowEmailPasswordLogin?: boolean +} + +export type GetWebAppWhitelistSubjectsRes = { + groups?: Array + members?: Array +} + +export type GetWebAppWhitelistSubjectsResMember = { + id?: string + name?: string + email?: string + avatar?: string +} + +export type GetWorkspacePermissionReply = { + permission?: WorkspacePermission +} + +export type GetWorkspaceReply = { + workspace?: Workspace +} + +export type GroupAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + token_usage?: string + rpm?: string + concurrency?: string +} + +export type HealthzReply = { + message?: string + status?: string +} + +export type InfoConfigReply = { + SSOEnforcedForSignin?: boolean + SSOEnforcedForSigninProtocol?: string + SSOEnforcedForWeb?: boolean + SSOEnforcedForWebProtocol?: string + EnableEmailCodeLogin?: boolean + EnableEmailPasswordLogin?: boolean + IsAllowRegister?: boolean + IsAllowCreateWorkspace?: boolean + License?: LicenseStatus + Branding?: BrandingInfo + WebAppAuth?: WebAppAuthInfo + PluginInstallationPermission?: PluginInstallationPermissionInfo +} + +export type InnerAdmission = { + marker?: string + concurrencyGroupIds?: Array +} + +export type InnerBatchGetWebAppAccessModesByIdReq = { + appIds?: Array +} + +export type InnerBatchGetWebAppAccessModesByIdRes = { + accessModes?: { + [key: string]: string + } +} + +export type InnerBatchIsUserAllowedToAccessWebAppReq = { + userId?: string + appIds?: Array +} + +export type InnerBatchIsUserAllowedToAccessWebAppRes = { + permissions?: { + [key: string]: boolean + } +} + +export type InnerCleanAppRes = { + message?: string +} + +export type InnerGetWebAppAccessModeByCodeRes = { + accessMode?: string +} + +export type InnerGetWebAppAccessModeByIdRes = { + accessMode?: string +} + +export type InnerGroupConfig = { + id?: string + enabled?: boolean + membershipId?: string + limits?: Array +} + +export type InnerIsUserAllowedToAccessWebAppRes = { + result?: boolean +} + +export type InnerReleaseAdmissionRequest = { + admission?: InnerAdmission +} + +export type InnerReleaseAdmissionResponse = { + [key: string]: unknown +} + +export type InnerResolveResponse = { + appId?: string + groups?: Array + blocked?: boolean + blockGroupId?: string + blockReason?: string + admission?: InnerAdmission +} + +export type InnerTryAddAccountToDefaultWorkspaceReply = { + workspaceId?: string + joined?: boolean + message?: string +} + +export type InnerTryAddAccountToDefaultWorkspaceReq = { + accountId?: string +} + +export type IsUserAllowedToAccessWebAppRes = { + result?: boolean +} + +export type JoinWorkspaceReply = { + message?: string +} + +export type JoinWorkspaceReq = { + id?: string + email?: string + role?: string +} + +export type LicenseInfo = { + uuid?: string + expiredAt?: string + clusterId?: string + product?: string + limits?: LimitFields +} + +export type LicenseStatus = { + status?: string + expiredAt?: string + workspaces?: ResourceQuota +} + +export type LimitConfig = { + type?: number + threshold?: string + action?: number + reached?: boolean +} + +export type LimitFields = { + workspaceMembers?: number + workspaces?: ResourceQuota +} + +export type ListGroupAppsResponse = { + items?: Array + total?: string +} + +export type ListMembersReply = { + data?: Array + pagination?: Pagination +} + +export type ListResourceGroupsResponse = { + items?: Array + total?: string +} + +export type ListSecretKeysReply = { + data?: Array + pagination?: Pagination +} + +export type ListUsersReply = { + data?: Array + pagination?: Pagination +} + +export type ListWorkspacesReply = { + data?: Array + pagination?: Pagination +} + +export type LoginTypesReply = { + enabledEmailCodeLogin?: boolean + enableEmailPasswordLogin?: boolean + isAllowRegister?: boolean + isAllowCreateWorkspace?: boolean +} + +export type LoginTypesReq = { + enabledEmailCodeLogin?: boolean + enableEmailPasswordLogin?: boolean + isAllowRegister?: boolean + isAllowCreateWorkspace?: boolean +} + +export type MfaBackupCodesRes = { + codes?: Array + validCounts?: number + createdAt?: string +} + +export type MfaDeleteBackupCodesRes = { + message?: string +} + +export type MfaDeleteRes = { + token?: string +} + +export type MfaDownloadBackupCodesSummaryRes = { + content?: string +} + +export type MfaEnrollReq = { + code?: string +} + +export type MfaEnrollRes = { + token?: string +} + +export type MfaGetEnrollInfoRes = { + qrCode?: string + secret?: string +} + +export type MfaModifyRes = { + message?: string +} + +export type OAuth2Config = { + clientId?: string + clientSecret?: string + authUrl?: string + tokenUrl?: string + userinfoUrl?: string + scopes?: string + enablePkce?: boolean +} + +export type OAuth2LoginReply = { + url?: string + state?: string +} + +export type OidcConfig = { + issuerUrl?: string + clientId?: string + clientSecret?: string + enablePkce?: boolean +} + +export type OidcReply = { + url?: string + state?: string +} + +export type OtelExporterEndpoint = { + endpoint?: string + compression?: string + protocol?: number + timeout?: string + headers?: { + [key: string]: string + } + tlsCaPem?: string + tlsInsecure?: boolean + tlsClientCertPem?: string + tlsClientKeyPem?: string + enabled?: boolean + tlsInsecureSkipVerify?: boolean +} + +export type OtelExporterStatusReply = { + connectedAt?: string + bytesPushed?: string + itemsInQueue?: string + logs?: string + status?: number +} + +export type PasswordPolicyConfig = { + minLength?: number + requireDigit?: boolean + requireLowercase?: boolean + requireUppercase?: boolean + requireSpecial?: boolean + forbidRepeated?: boolean + forbidSequential?: boolean + expiryEnabled?: boolean + expiryDays?: number +} + +export type PasswordStrengthReply = { + level?: number +} + +export type PasswordStrengthReq = { + password?: string +} + +export type PluginInstallationPermissionInfo = { + pluginInstallationScope?: string + restrictToMarketplaceOnly?: boolean +} + +export type PluginInstallationSettingsReply = { + pluginInstallationScope?: number + restrictToMarketplaceOnly?: boolean +} + +export type ResetMemberPasswordReply = { + id?: string + password?: string +} + +export type ResetMemberPasswordReq = { + id?: string +} + +export type ResetPasswordReply = { + message?: string +} + +export type ResetPasswordReq = { + currentPassword?: string + newPassword?: string + confirmPassword?: string +} + +export type ResetUserPasswordReply = { + id?: string + password?: string +} + +export type ResetUserPasswordReq = { + id?: string +} + +export type ResourceGroupDetail = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number + created_at?: string + updated_at?: string +} + +export type ResourceGroupItem = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + concurrency_limit?: number + token_quota?: string + token_usage?: string + app_count?: string + rpm_status?: number + conc_status?: number + created_at?: string + updated_at?: string +} + +export type ResourceQuota = { + used?: number + limit?: number + enabled?: boolean +} + +export type SamlConfig = { + idpSsoUrl?: string + certificate?: string +} + +export type SamlLoginReply = { + url?: string +} + +export type SsoIdPProvider = { + protocol?: string + provider?: string + samlConfig?: SamlConfig + oidcConfig?: OidcConfig + oauth2Config?: OAuth2Config +} + +export type SsoSettings = { + ssoEnforced?: boolean + sessionTimeout?: number + ssoIdpProvider?: SsoIdPProvider +} + +export type SsoSettingsReply = { + enabled?: boolean +} + +export type ScimSettings = { + enabled?: boolean + lastSyncTime?: string +} + +export type SearchAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + icon?: string + icon_type?: string + icon_background?: string + created_by_name?: string +} + +export type SearchAppsResponse = { + items?: Array + total?: string +} + +export type SearchForWhilteListCandidatesRes = { + subjects?: Array + currPage?: number + hasMore?: boolean +} + +export type SecretKey = { + id?: string + name?: string + secretKeyMasked?: string + createdAt?: string + lastActive?: string +} + +export type SetDefaultWorkspaceReply = { + workspaceId?: string +} + +export type SetDefaultWorkspaceReq = { + id?: string +} + +export type Subject = { + subjectId?: string + subjectType?: string + accountData?: SubjectAccountData + groupData?: SubjectGroupData +} + +export type SubjectAccountData = { + id?: string + name?: string + email?: string + avatar?: string +} + +export type SubjectGroupData = { + id?: string + name?: string + groupSize?: number +} + +export type SystemUserSettingReply = { + isAllowRegister?: boolean + enableEmailPasswordLogin?: boolean +} + +export type SystemUserSettingReq = { + isAllowRegister?: boolean + enableEmailPasswordLogin?: boolean +} + +export type TestConnectionReply = { + success?: boolean + error?: string +} + +export type ToggleEndpointRequest = { + enabled?: boolean +} + +export type UpdateAccessModeReq = { + appId?: string + accessMode?: string +} + +export type UpdateAccessModeRes = { + message?: string +} + +export type UpdateBrandingInfoReq = { + enabled?: boolean + applicationTitle?: string + loginPageLogo?: string + workspaceLogo?: string + favicon?: string +} + +export type UpdateGroupSubjectsReq = { + groupId?: string + subjects?: Array +} + +export type UpdateGroupSubjectsRes = { + message?: string +} + +export type UpdateGroupsReq = { + groups?: Array +} + +export type UpdateGroupsReqGroup = { + id?: string + name?: string +} + +export type UpdateGroupsRes = { + groups?: Array +} + +export type UpdateJoinedGroupsReq = { + accountId?: string + groupIds?: Array +} + +export type UpdateJoinedGroupsRes = { + message?: string +} + +export type UpdateLicenseReply = { + message?: string +} + +export type UpdateLicenseReq = { + licenseId?: string +} + +export type UpdateMfaStatusReq = { + enabled?: boolean +} + +export type UpdateMfaStatusRes = { + message?: string +} + +export type UpdateMemberReply = { + account?: Account +} + +export type UpdateMemberReq = { + id?: string + name?: string + email?: string + status?: string +} + +export type UpdateMembersInGroupsReq = { + groupId?: string + accountIds?: Array +} + +export type UpdateMembersInGroupsRes = { + message?: string +} + +export type UpdateOfflineLicenseReply = { + message?: string +} + +export type UpdateOfflineLicenseReq = { + offlineCode?: string +} + +export type UpdatePluginInstallationSettingsRequest = { + pluginInstallationScope?: number + restrictToMarketplaceOnly?: boolean +} + +export type UpdateResourceGroupRequest = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number +} + +export type UpdateUserReply = { + account?: AccountDetail +} + +export type UpdateUserReq = { + id?: string + name?: string + email?: string + status?: string +} + +export type UpdateWebAppAuthInfoReq = { + allowSso?: boolean + allowEmailCodeLogin?: boolean + allowEmailPasswordLogin?: boolean +} + +export type UpdateWebAppAuthInfoRes = { + message?: string +} + +export type UpdateWebAppWhitelistSubjectsReq = { + appId?: string + subjects?: Array + accessMode?: string +} + +export type UpdateWebAppWhitelistSubjectsRes = { + message?: string +} + +export type UpdateWorkspacePermissionReply = { + message?: string + permission?: WorkspacePermission +} + +export type UpdateWorkspacePermissionReq = { + id?: string + permission?: WorkspacePermission +} + +export type UpdateWorkspaceReply = { + workspace?: Workspace +} + +export type UpdateWorkspaceReq = { + id?: string + name?: string + email?: string + status?: string +} + +export type WebAppAuthInfo = { + allowSso?: boolean + allowEmailCodeLogin?: boolean + allowEmailPasswordLogin?: boolean +} + +export type WebOAuth2LoginReply = { + url?: string + state?: string +} + +export type WebOidcLoginReply = { + url?: string +} + +export type WebSamlLoginReply = { + url?: string +} + +export type Workspace = { + id?: string + name?: string + status?: string + createdAt?: string + owner?: Account +} + +export type WorkspaceInfoReply = { + WorkspaceMembers?: ResourceQuota +} + +export type WorkspacePermission = { + workspaceId?: string + allowMemberInvite?: boolean + allowOwnerTransfer?: boolean +} + +export type Pagination = { + totalCount?: number + perPage?: number + currentPage?: number + totalPages?: number +} + +export type ConsoleSsoOAuth2LoginData = { + body?: never + path?: never + query?: never + url: '/enterprise/sso/oauth2/login' +} + +export type ConsoleSsoOAuth2LoginResponses = { + 200: OAuth2LoginReply +} + +export type ConsoleSsoOAuth2LoginResponse + = ConsoleSsoOAuth2LoginResponses[keyof ConsoleSsoOAuth2LoginResponses] + +export type ConsoleSsoOidcLoginData = { + body?: never + path?: never + query?: never + url: '/enterprise/sso/oidc/login' +} + +export type ConsoleSsoOidcLoginResponses = { + 200: OidcReply +} + +export type ConsoleSsoOidcLoginResponse + = ConsoleSsoOidcLoginResponses[keyof ConsoleSsoOidcLoginResponses] + +export type ConsoleSsoSamlLoginData = { + body?: never + path?: never + query?: never + url: '/enterprise/sso/saml/login' +} + +export type ConsoleSsoSamlLoginResponses = { + 200: SamlLoginReply +} + +export type ConsoleSsoSamlLoginResponse + = ConsoleSsoSamlLoginResponses[keyof ConsoleSsoSamlLoginResponses] + +export type WebAppAuthGetWebAppAccessModeData = { + body?: never + path?: never + query?: { + appId?: string + } + url: '/enterprise/webapp/app/access-mode' +} + +export type WebAppAuthGetWebAppAccessModeResponses = { + 200: GetWebAppAccessModeRes +} + +export type WebAppAuthGetWebAppAccessModeResponse + = WebAppAuthGetWebAppAccessModeResponses[keyof WebAppAuthGetWebAppAccessModeResponses] + +export type WebAppAuthUpdateWebAppWhitelistSubjectsData = { + body: UpdateWebAppWhitelistSubjectsReq + path?: never + query?: never + url: '/enterprise/webapp/app/access-mode' +} + +export type WebAppAuthUpdateWebAppWhitelistSubjectsResponses = { + 200: UpdateWebAppWhitelistSubjectsRes +} + +export type WebAppAuthUpdateWebAppWhitelistSubjectsResponse + = WebAppAuthUpdateWebAppWhitelistSubjectsResponses[keyof WebAppAuthUpdateWebAppWhitelistSubjectsResponses] + +export type WebAppAuthSearchForWhilteListCandidatesData = { + body?: never + path?: never + query?: { + keyword?: string + pageNumber?: number + resultsPerPage?: number + groupId?: string + } + url: '/enterprise/webapp/app/subject/search' +} + +export type WebAppAuthSearchForWhilteListCandidatesResponses = { + 200: SearchForWhilteListCandidatesRes +} + +export type WebAppAuthSearchForWhilteListCandidatesResponse + = WebAppAuthSearchForWhilteListCandidatesResponses[keyof WebAppAuthSearchForWhilteListCandidatesResponses] + +export type WebAppAuthGetWebAppWhitelistSubjectsData = { + body?: never + path?: never + query?: { + appId?: string + } + url: '/enterprise/webapp/app/subjects' +} + +export type WebAppAuthGetWebAppWhitelistSubjectsResponses = { + 200: GetWebAppWhitelistSubjectsRes +} + +export type WebAppAuthGetWebAppWhitelistSubjectsResponse + = WebAppAuthGetWebAppWhitelistSubjectsResponses[keyof WebAppAuthGetWebAppWhitelistSubjectsResponses] + +export type WebAppAuthGetGroupSubjectsData = { + body?: never + path?: never + query?: { + groupId?: string + } + url: '/enterprise/webapp/group/subjects' +} + +export type WebAppAuthGetGroupSubjectsResponses = { + 200: GetGroupSubjectsRes +} + +export type WebAppAuthGetGroupSubjectsResponse + = WebAppAuthGetGroupSubjectsResponses[keyof WebAppAuthGetGroupSubjectsResponses] + +export type WebAppAuthIsUserAllowedToAccessWebAppData = { + body?: never + path?: never + query?: { + appId?: string + } + url: '/enterprise/webapp/permission' +} + +export type WebAppAuthIsUserAllowedToAccessWebAppResponses = { + 200: IsUserAllowedToAccessWebAppRes +} + +export type WebAppAuthIsUserAllowedToAccessWebAppResponse + = WebAppAuthIsUserAllowedToAccessWebAppResponses[keyof WebAppAuthIsUserAllowedToAccessWebAppResponses] diff --git a/web/contract/generated/enterprise/zod.gen.ts b/web/contract/generated/enterprise/zod.gen.ts new file mode 100644 index 0000000000..cef500a906 --- /dev/null +++ b/web/contract/generated/enterprise/zod.gen.ts @@ -0,0 +1,1183 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +/** + * Account represents a basic user account + */ +export const zAccount = z.object({ + id: z.string().optional(), + email: z.string().optional(), + name: z.string().optional(), +}) + +export const zAccountDetailGroup = z.object({ + id: z.string().optional(), + name: z.string().optional(), +}) + +/** + * AccountInWorkspace represents account's role in a workspace + */ +export const zAccountInWorkspace = z.object({ + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), + role: z.string().optional(), +}) + +/** + * AccountDetail contains detailed account information + */ +export const zAccountDetail = z.object({ + account: zAccount.optional(), + status: z.string().optional(), + createdAt: z.iso.datetime().optional(), + lastActiveAt: z.iso.datetime().optional(), + workspaces: z.array(zAccountInWorkspace).optional(), + groups: z.array(zAccountDetailGroup).optional(), +}) + +export const zAddGroupAppsRequest = z.object({ + id: z.string().optional(), + app_ids: z.array(z.string()).optional(), +}) + +export const zBrandingInfo = z.object({ + enabled: z.boolean().optional(), + applicationTitle: z.string().optional(), + loginPageLogo: z.string().optional(), + workspaceLogo: z.string().optional(), + favicon: z.string().optional(), +}) + +export const zCheckPasswordStatusReply = z.object({ + requirePasswordChange: z.boolean().optional(), + changeReason: z.int().optional(), + daysToExpire: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + message: z.string().optional(), +}) + +export const zClearDefaultWorkspaceReply = z.record(z.string(), z.unknown()) + +export const zCreateBearerTokenResponse = z.object({ + token: z.string().optional(), +}) + +export const zCreateMemberReply = z.object({ + id: z.string().optional(), + password: z.string().optional(), +}) + +/** + * Create member messages + */ +export const zCreateMemberReq = z.object({ + name: z.string().optional(), + email: z.string().optional(), + status: z.string().optional(), +}) + +export const zCreateNewGroupsReqGroup = z.object({ + name: z.string().optional(), +}) + +export const zCreateNewGroupsReq = z.object({ + groups: z.array(zCreateNewGroupsReqGroup).optional(), +}) + +export const zCreateResourceGroupRequest = z.object({ + name: z.string().optional(), + description: z.string().optional(), +}) + +export const zCreateSecretKeyReply = z.object({ + id: z.string().optional(), + name: z.string().optional(), + secretKey: z.string().optional(), + createdAt: z.iso.datetime().optional(), + lastActive: z.iso.datetime().optional(), +}) + +export const zCreateSecretKeyReq = z.object({ + name: z.string().optional(), +}) + +export const zCreateUserReply = z.object({ + id: z.string().optional(), + password: z.string().optional(), +}) + +export const zCreateUserReq = z.object({ + name: z.string().optional(), + email: z.string().optional(), + status: z.string().optional(), +}) + +/** + * Create workspace messages + */ +export const zCreateWorkspaceReq = z.object({ + name: z.string().optional(), + email: z.string().optional(), + status: z.string().optional(), +}) + +export const zCurrentUserReply = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + interfaceLanguage: z.string().optional(), + timezone: z.string().optional(), +}) + +export const zDashboardSsooidcLoginReply = z.object({ + url: z.string().optional(), + state: z.string().optional(), +}) + +export const zDashboardSsoOauth2LoginReply = z.object({ + url: z.string().optional(), + state: z.string().optional(), +}) + +/** + * Dashboard SSO Login messages + */ +export const zDashboardSsosamlLoginReply = z.object({ + url: z.string().optional(), +}) + +export const zDeleteGroupsRes = z.object({ + message: z.string().optional(), +}) + +export const zDeleteMemberReply = z.object({ + account: zAccount.optional(), +}) + +export const zDeleteSecretKeyReply = z.object({ + message: z.string().optional(), +}) + +export const zDeleteUserReply = z.object({ + account: zAccount.optional(), +}) + +export const zDeleteWorkspaceReply = z.record(z.string(), z.unknown()) + +/** + * System user setting messages + */ +export const zEnterpriseSystemUserSettingReply = z.object({ + ssoEnforcedForSignin: z.boolean().optional(), + ssoEnforcedForSigninProtocol: z.string().optional(), + enableEmailPasswordLogin: z.boolean().optional(), +}) + +export const zGetBearerTokenResponse = z.object({ + maskedToken: z.string().optional(), +}) + +export const zGetClusterInfoReply = z.object({ + mode: z.string().optional(), + clusterId: z.string().optional(), + verifyMode: z.string().optional(), +}) + +export const zGetLicenseStatusReply = z.object({ + status: z.string().optional(), +}) + +export const zGetMfaInfoReply = z.object({ + userEnabled: z.boolean().optional(), + userSetup: z.boolean().optional(), + globalEnabled: z.boolean().optional(), +}) + +export const zGetMemberReply = z.object({ + account: zAccountDetail.optional(), +}) + +export const zGetUserReply = z.object({ + account: zAccountDetail.optional(), +}) + +export const zGetWebAppAccessModeRes = z.object({ + accessMode: z.string().optional(), +}) + +export const zGetWebAppAuthInfoRes = z.object({ + allowSso: z.boolean().optional(), + allowEmailCodeLogin: z.boolean().optional(), + allowEmailPasswordLogin: z.boolean().optional(), +}) + +export const zGetWebAppWhitelistSubjectsResMember = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + avatar: z.string().optional(), +}) + +export const zGroupAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + token_usage: z.string().optional(), + rpm: z.string().optional(), + concurrency: z.string().optional(), +}) + +export const zHealthzReply = z.object({ + message: z.string().optional(), + status: z.string().optional(), +}) + +export const zInnerAdmission = z.object({ + marker: z.string().optional(), + concurrencyGroupIds: z.array(z.string()).optional(), +}) + +export const zInnerBatchGetWebAppAccessModesByIdReq = z.object({ + appIds: z.array(z.string()).optional(), +}) + +export const zInnerBatchGetWebAppAccessModesByIdRes = z.object({ + accessModes: z.record(z.string(), z.string()).optional(), +}) + +export const zInnerBatchIsUserAllowedToAccessWebAppReq = z.object({ + userId: z.string().optional(), + appIds: z.array(z.string()).optional(), +}) + +export const zInnerBatchIsUserAllowedToAccessWebAppRes = z.object({ + permissions: z.record(z.string(), z.boolean()).optional(), +}) + +export const zInnerCleanAppRes = z.object({ + message: z.string().optional(), +}) + +export const zInnerGetWebAppAccessModeByCodeRes = z.object({ + accessMode: z.string().optional(), +}) + +export const zInnerGetWebAppAccessModeByIdRes = z.object({ + accessMode: z.string().optional(), +}) + +export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ + result: z.boolean().optional(), +}) + +export const zInnerReleaseAdmissionRequest = z.object({ + admission: zInnerAdmission.optional(), +}) + +export const zInnerReleaseAdmissionResponse = z.record(z.string(), z.unknown()) + +export const zInnerTryAddAccountToDefaultWorkspaceReply = z.object({ + workspaceId: z.string().optional(), + joined: z.boolean().optional(), + message: z.string().optional(), +}) + +/** + * Inner API messages + */ +export const zInnerTryAddAccountToDefaultWorkspaceReq = z.object({ + accountId: z.string().optional(), +}) + +export const zIsUserAllowedToAccessWebAppRes = z.object({ + result: z.boolean().optional(), +}) + +export const zJoinWorkspaceReply = z.object({ + message: z.string().optional(), +}) + +/** + * Join workspace messages + */ +export const zJoinWorkspaceReq = z.object({ + id: z.string().optional(), + email: z.string().optional(), + role: z.string().optional(), +}) + +export const zLimitConfig = z.object({ + type: z.int().optional(), + threshold: z.string().optional(), + action: z.int().optional(), + reached: z.boolean().optional(), +}) + +export const zInnerGroupConfig = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), + membershipId: z.string().optional(), + limits: z.array(zLimitConfig).optional(), +}) + +export const zInnerResolveResponse = z.object({ + appId: z.string().optional(), + groups: z.array(zInnerGroupConfig).optional(), + blocked: z.boolean().optional(), + blockGroupId: z.string().optional(), + blockReason: z.string().optional(), + admission: zInnerAdmission.optional(), +}) + +export const zListGroupAppsResponse = z.object({ + items: z.array(zGroupAppItem).optional(), + total: z.string().optional(), +}) + +export const zLoginTypesReply = z.object({ + enabledEmailCodeLogin: z.boolean().optional(), + enableEmailPasswordLogin: z.boolean().optional(), + isAllowRegister: z.boolean().optional(), + isAllowCreateWorkspace: z.boolean().optional(), +}) + +export const zLoginTypesReq = z.object({ + enabledEmailCodeLogin: z.boolean().optional(), + enableEmailPasswordLogin: z.boolean().optional(), + isAllowRegister: z.boolean().optional(), + isAllowCreateWorkspace: z.boolean().optional(), +}) + +export const zMfaBackupCodesRes = z.object({ + codes: z.array(z.string()).optional(), + validCounts: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + createdAt: z.iso.datetime().optional(), +}) + +export const zMfaDeleteBackupCodesRes = z.object({ + message: z.string().optional(), +}) + +export const zMfaDeleteRes = z.object({ + token: z.string().optional(), +}) + +export const zMfaDownloadBackupCodesSummaryRes = z.object({ + content: z.string().optional(), +}) + +export const zMfaEnrollReq = z.object({ + code: z.string().optional(), +}) + +export const zMfaEnrollRes = z.object({ + token: z.string().optional(), +}) + +export const zMfaGetEnrollInfoRes = z.object({ + qrCode: z.string().optional(), + secret: z.string().optional(), +}) + +export const zMfaModifyRes = z.object({ + message: z.string().optional(), +}) + +export const zOAuth2Config = z.object({ + clientId: z.string().optional(), + clientSecret: z.string().optional(), + authUrl: z.string().optional(), + tokenUrl: z.string().optional(), + userinfoUrl: z.string().optional(), + scopes: z.string().optional(), + enablePkce: z.boolean().optional(), +}) + +export const zOAuth2LoginReply = z.object({ + url: z.string().optional(), + state: z.string().optional(), +}) + +export const zOidcConfig = z.object({ + issuerUrl: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + enablePkce: z.boolean().optional(), +}) + +export const zOidcReply = z.object({ + url: z.string().optional(), + state: z.string().optional(), +}) + +export const zOtelExporterEndpoint = z.object({ + endpoint: z.string().optional(), + compression: z.string().optional(), + protocol: z.int().optional(), + timeout: z + .string() + .regex(/^-?(?:0|[1-9]\d{0,11})(?:\.\d{1,9})?s$/) + .optional(), + headers: z.record(z.string(), z.string()).optional(), + tlsCaPem: z.string().optional(), + tlsInsecure: z.boolean().optional(), + tlsClientCertPem: z.string().optional(), + tlsClientKeyPem: z.string().optional(), + enabled: z.boolean().optional(), + tlsInsecureSkipVerify: z.boolean().optional(), +}) + +export const zEndpointReply = z.object({ + mode: z.int().optional(), + metricsEndpoint: zOtelExporterEndpoint.optional(), + tracesEndpoint: zOtelExporterEndpoint.optional(), +}) + +export const zOtelExporterStatusReply = z.object({ + connectedAt: z.iso.datetime().optional(), + bytesPushed: z.string().optional(), + itemsInQueue: z.string().optional(), + logs: z.string().optional(), + status: z.int().optional(), +}) + +export const zPasswordPolicyConfig = z.object({ + minLength: z + .int() + .min(0, { error: 'Invalid value: Expected uint32 to be >= 0' }) + .max(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + .optional(), + requireDigit: z.boolean().optional(), + requireLowercase: z.boolean().optional(), + requireUppercase: z.boolean().optional(), + requireSpecial: z.boolean().optional(), + forbidRepeated: z.boolean().optional(), + forbidSequential: z.boolean().optional(), + expiryEnabled: z.boolean().optional(), + expiryDays: z + .int() + .min(0, { error: 'Invalid value: Expected uint32 to be >= 0' }) + .max(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + .optional(), +}) + +export const zPasswordStrengthReply = z.object({ + level: z.int().optional(), +}) + +export const zPasswordStrengthReq = z.object({ + password: z.string().optional(), +}) + +export const zPluginInstallationPermissionInfo = z.object({ + pluginInstallationScope: z.string().optional(), + restrictToMarketplaceOnly: z.boolean().optional(), +}) + +export const zPluginInstallationSettingsReply = z.object({ + pluginInstallationScope: z.int().optional(), + restrictToMarketplaceOnly: z.boolean().optional(), +}) + +export const zResetMemberPasswordReply = z.object({ + id: z.string().optional(), + password: z.string().optional(), +}) + +/** + * Reset member password messages + */ +export const zResetMemberPasswordReq = z.object({ + id: z.string().optional(), +}) + +export const zResetPasswordReply = z.object({ + message: z.string().optional(), +}) + +/** + * Password reset messages + */ +export const zResetPasswordReq = z.object({ + currentPassword: z.string().optional(), + newPassword: z.string().optional(), + confirmPassword: z.string().optional(), +}) + +export const zResetUserPasswordReply = z.object({ + id: z.string().optional(), + password: z.string().optional(), +}) + +export const zResetUserPasswordReq = z.object({ + id: z.string().optional(), +}) + +export const zResourceGroupDetail = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), +}) + +export const zResourceGroupItem = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + token_quota: z.string().optional(), + token_usage: z.string().optional(), + app_count: z.string().optional(), + rpm_status: z.int().optional(), + conc_status: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), +}) + +export const zListResourceGroupsResponse = z.object({ + items: z.array(zResourceGroupItem).optional(), + total: z.string().optional(), +}) + +/** + * ResourceQuota represents usage quota for a resource + */ +export const zResourceQuota = z.object({ + used: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + enabled: z.boolean().optional(), +}) + +export const zLicenseStatus = z.object({ + status: z.string().optional(), + expiredAt: z.string().optional(), + workspaces: zResourceQuota.optional(), +}) + +export const zLimitFields = z.object({ + workspaceMembers: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + workspaces: zResourceQuota.optional(), +}) + +/** + * License information + */ +export const zLicenseInfo = z.object({ + uuid: z.string().optional(), + expiredAt: z.iso.datetime().optional(), + clusterId: z.string().optional(), + product: z.string().optional(), + limits: zLimitFields.optional(), +}) + +/** + * License RPC messages + */ +export const zGetLicenseReply = z.object({ + license: zLicenseInfo.optional(), +}) + +/** + * SSO Configuration messages + */ +export const zSamlConfig = z.object({ + idpSsoUrl: z.string().optional(), + certificate: z.string().optional(), +}) + +export const zSamlLoginReply = z.object({ + url: z.string().optional(), +}) + +export const zSsoIdPProvider = z.object({ + protocol: z.string().optional(), + provider: z.string().optional(), + samlConfig: zSamlConfig.optional(), + oidcConfig: zOidcConfig.optional(), + oauth2Config: zOAuth2Config.optional(), +}) + +export const zSsoSettings = z.object({ + ssoEnforced: z.boolean().optional(), + sessionTimeout: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + ssoIdpProvider: zSsoIdPProvider.optional(), +}) + +export const zAuthSettingsReply = z.object({ + userSsoSettings: zSsoSettings.optional(), + webSsoSettings: zSsoSettings.optional(), + dashboardSsoSettings: zSsoSettings.optional(), + userSsoSamlAcsUrl: z.string().optional(), + userSsoOidcCallbackUrl: z.string().optional(), + userSsoOauth2CallbackUrl: z.string().optional(), + webSsoSamlAcsUrl: z.string().optional(), + webSsoOidcCallbackUrl: z.string().optional(), + webSsoOauth2CallbackUrl: z.string().optional(), + webSsoMembersSamlAcsUrl: z.string().optional(), + webSsoMembersOidcCallbackUrl: z.string().optional(), + webSsoMembersOauth2CallbackUrl: z.string().optional(), + dashboardSsoSamlAcsUrl: z.string().optional(), + dashboardSsoOidcCallbackUrl: z.string().optional(), + dashboardSsoOauth2CallbackUrl: z.string().optional(), +}) + +export const zAuthSettingsReq = z.object({ + ssoType: z.string().optional(), + ssoSettings: zSsoSettings.optional(), +}) + +export const zSsoSettingsReply = z.object({ + enabled: z.boolean().optional(), +}) + +export const zScimSettings = z.object({ + enabled: z.boolean().optional(), + lastSyncTime: z.iso.datetime().optional(), +}) + +export const zSearchAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + icon: z.string().optional(), + icon_type: z.string().optional(), + icon_background: z.string().optional(), + created_by_name: z.string().optional(), +}) + +export const zSearchAppsResponse = z.object({ + items: z.array(zSearchAppItem).optional(), + total: z.string().optional(), +}) + +export const zSecretKey = z.object({ + id: z.string().optional(), + name: z.string().optional(), + secretKeyMasked: z.string().optional(), + createdAt: z.iso.datetime().optional(), + lastActive: z.iso.datetime().optional(), +}) + +export const zSetDefaultWorkspaceReply = z.object({ + workspaceId: z.string().optional(), +}) + +export const zSetDefaultWorkspaceReq = z.object({ + id: z.string().optional(), +}) + +export const zSubjectAccountData = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + avatar: z.string().optional(), +}) + +export const zSubjectGroupData = z.object({ + id: z.string().optional(), + name: z.string().optional(), + groupSize: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zCreateNewGroupsRes = z.object({ + groups: z.array(zSubjectGroupData).optional(), +}) + +export const zGetGroupsRes = z.object({ + groups: z.array(zSubjectGroupData).optional(), +}) + +export const zGetJoinedGroupsRes = z.object({ + groups: z.array(zSubjectGroupData).optional(), +}) + +export const zGetWebAppWhitelistSubjectsRes = z.object({ + groups: z.array(zSubjectGroupData).optional(), + members: z.array(zGetWebAppWhitelistSubjectsResMember).optional(), +}) + +/** + * Subject represents a subject (user or group) in access control + */ +export const zSubject = z.object({ + subjectId: z.string().optional(), + subjectType: z.string().optional(), + accountData: zSubjectAccountData.optional(), + groupData: zSubjectGroupData.optional(), +}) + +export const zGetGroupSubjectsRes = z.object({ + subjects: z.array(zSubject).optional(), +}) + +export const zSearchForWhilteListCandidatesRes = z.object({ + subjects: z.array(zSubject).optional(), + currPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + hasMore: z.boolean().optional(), +}) + +export const zSystemUserSettingReply = z.object({ + isAllowRegister: z.boolean().optional(), + enableEmailPasswordLogin: z.boolean().optional(), +}) + +export const zSystemUserSettingReq = z.object({ + isAllowRegister: z.boolean().optional(), + enableEmailPasswordLogin: z.boolean().optional(), +}) + +export const zTestConnectionReply = z.object({ + success: z.boolean().optional(), + error: z.string().optional(), +}) + +export const zToggleEndpointRequest = z.object({ + enabled: z.boolean().optional(), +}) + +export const zUpdateAccessModeReq = z.object({ + appId: z.string().optional(), + accessMode: z.string().optional(), +}) + +export const zUpdateAccessModeRes = z.object({ + message: z.string().optional(), +}) + +export const zUpdateBrandingInfoReq = z.object({ + enabled: z.boolean().optional(), + applicationTitle: z.string().optional(), + loginPageLogo: z.string().optional(), + workspaceLogo: z.string().optional(), + favicon: z.string().optional(), +}) + +export const zUpdateGroupSubjectsReq = z.object({ + groupId: z.string().optional(), + subjects: z.array(zSubject).optional(), +}) + +export const zUpdateGroupSubjectsRes = z.object({ + message: z.string().optional(), +}) + +export const zUpdateGroupsReqGroup = z.object({ + id: z.string().optional(), + name: z.string().optional(), +}) + +export const zUpdateGroupsReq = z.object({ + groups: z.array(zUpdateGroupsReqGroup).optional(), +}) + +export const zUpdateGroupsRes = z.object({ + groups: z.array(zSubjectGroupData).optional(), +}) + +export const zUpdateJoinedGroupsReq = z.object({ + accountId: z.string().optional(), + groupIds: z.array(z.string()).optional(), +}) + +export const zUpdateJoinedGroupsRes = z.object({ + message: z.string().optional(), +}) + +export const zUpdateLicenseReply = z.object({ + message: z.string().optional(), +}) + +export const zUpdateLicenseReq = z.object({ + licenseId: z.string().optional(), +}) + +export const zUpdateMfaStatusReq = z.object({ + enabled: z.boolean().optional(), +}) + +export const zUpdateMfaStatusRes = z.object({ + message: z.string().optional(), +}) + +export const zUpdateMemberReply = z.object({ + account: zAccount.optional(), +}) + +/** + * Update member messages + */ +export const zUpdateMemberReq = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + status: z.string().optional(), +}) + +export const zUpdateMembersInGroupsReq = z.object({ + groupId: z.string().optional(), + accountIds: z.array(z.string()).optional(), +}) + +export const zUpdateMembersInGroupsRes = z.object({ + message: z.string().optional(), +}) + +export const zUpdateOfflineLicenseReply = z.object({ + message: z.string().optional(), +}) + +export const zUpdateOfflineLicenseReq = z.object({ + offlineCode: z.string().optional(), +}) + +export const zUpdatePluginInstallationSettingsRequest = z.object({ + pluginInstallationScope: z.int().optional(), + restrictToMarketplaceOnly: z.boolean().optional(), +}) + +export const zUpdateResourceGroupRequest = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), +}) + +export const zUpdateUserReply = z.object({ + account: zAccountDetail.optional(), +}) + +export const zUpdateUserReq = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + status: z.string().optional(), +}) + +/** + * Web app auth info messages + */ +export const zUpdateWebAppAuthInfoReq = z.object({ + allowSso: z.boolean().optional(), + allowEmailCodeLogin: z.boolean().optional(), + allowEmailPasswordLogin: z.boolean().optional(), +}) + +export const zUpdateWebAppAuthInfoRes = z.object({ + message: z.string().optional(), +}) + +export const zUpdateWebAppWhitelistSubjectsReq = z.object({ + appId: z.string().optional(), + subjects: z.array(zSubject).optional(), + accessMode: z.string().optional(), +}) + +export const zUpdateWebAppWhitelistSubjectsRes = z.object({ + message: z.string().optional(), +}) + +/** + * Update workspace messages + */ +export const zUpdateWorkspaceReq = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + status: z.string().optional(), +}) + +export const zWebAppAuthInfo = z.object({ + allowSso: z.boolean().optional(), + allowEmailCodeLogin: z.boolean().optional(), + allowEmailPasswordLogin: z.boolean().optional(), +}) + +/** + * Info configuration messages + */ +export const zInfoConfigReply = z.object({ + SSOEnforcedForSignin: z.boolean().optional(), + SSOEnforcedForSigninProtocol: z.string().optional(), + SSOEnforcedForWeb: z.boolean().optional(), + SSOEnforcedForWebProtocol: z.string().optional(), + EnableEmailCodeLogin: z.boolean().optional(), + EnableEmailPasswordLogin: z.boolean().optional(), + IsAllowRegister: z.boolean().optional(), + IsAllowCreateWorkspace: z.boolean().optional(), + License: zLicenseStatus.optional(), + Branding: zBrandingInfo.optional(), + WebAppAuth: zWebAppAuthInfo.optional(), + PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(), +}) + +export const zWebOAuth2LoginReply = z.object({ + url: z.string().optional(), + state: z.string().optional(), +}) + +export const zWebOidcLoginReply = z.object({ + url: z.string().optional(), +}) + +export const zWebSamlLoginReply = z.object({ + url: z.string().optional(), +}) + +/** + * Workspace represents a workspace entity + */ +export const zWorkspace = z.object({ + id: z.string().optional(), + name: z.string().optional(), + status: z.string().optional(), + createdAt: z.iso.datetime().optional(), + owner: zAccount.optional(), +}) + +export const zCreateWorkspaceReply = z.object({ + workspace: zWorkspace.optional(), +}) + +export const zGetDefaultWorkspaceReply = z.object({ + workspaceId: z.string().optional(), + workspace: zWorkspace.optional(), +}) + +export const zGetWorkspaceReply = z.object({ + workspace: zWorkspace.optional(), +}) + +export const zUpdateWorkspaceReply = z.object({ + workspace: zWorkspace.optional(), +}) + +export const zWorkspaceInfoReply = z.object({ + WorkspaceMembers: zResourceQuota.optional(), +}) + +/** + * Workspace permission + */ +export const zWorkspacePermission = z.object({ + workspaceId: z.string().optional(), + allowMemberInvite: z.boolean().optional(), + allowOwnerTransfer: z.boolean().optional(), +}) + +export const zGetWorkspacePermissionReply = z.object({ + permission: zWorkspacePermission.optional(), +}) + +export const zUpdateWorkspacePermissionReply = z.object({ + message: z.string().optional(), + permission: zWorkspacePermission.optional(), +}) + +/** + * Update workspace permission messages + */ +export const zUpdateWorkspacePermissionReq = z.object({ + id: z.string().optional(), + permission: zWorkspacePermission.optional(), +}) + +/** + * Pagination : Just for pagination by page + */ +export const zPagination = z.object({ + totalCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + perPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + currentPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + totalPages: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zListMembersReply = z.object({ + data: z.array(zAccountDetail).optional(), + pagination: zPagination.optional(), +}) + +export const zListSecretKeysReply = z.object({ + data: z.array(zSecretKey).optional(), + pagination: zPagination.optional(), +}) + +export const zListUsersReply = z.object({ + data: z.array(zAccountDetail).optional(), + pagination: zPagination.optional(), +}) + +export const zListWorkspacesReply = z.object({ + data: z.array(zWorkspace).optional(), + pagination: zPagination.optional(), +}) + +/** + * OK + */ +export const zConsoleSsoOAuth2LoginResponse = zOAuth2LoginReply + +/** + * OK + */ +export const zConsoleSsoOidcLoginResponse = zOidcReply + +/** + * OK + */ +export const zConsoleSsoSamlLoginResponse = zSamlLoginReply + +export const zWebAppAuthGetWebAppAccessModeQuery = z.object({ + appId: z.string().optional(), +}) + +/** + * OK + */ +export const zWebAppAuthGetWebAppAccessModeResponse = zGetWebAppAccessModeRes + +export const zWebAppAuthUpdateWebAppWhitelistSubjectsBody = zUpdateWebAppWhitelistSubjectsReq + +/** + * OK + */ +export const zWebAppAuthUpdateWebAppWhitelistSubjectsResponse = zUpdateWebAppWhitelistSubjectsRes + +export const zWebAppAuthSearchForWhilteListCandidatesQuery = z.object({ + keyword: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + groupId: z.string().optional(), +}) + +/** + * OK + */ +export const zWebAppAuthSearchForWhilteListCandidatesResponse = zSearchForWhilteListCandidatesRes + +export const zWebAppAuthGetWebAppWhitelistSubjectsQuery = z.object({ + appId: z.string().optional(), +}) + +/** + * OK + */ +export const zWebAppAuthGetWebAppWhitelistSubjectsResponse = zGetWebAppWhitelistSubjectsRes + +export const zWebAppAuthGetGroupSubjectsQuery = z.object({ + groupId: z.string().optional(), +}) + +/** + * OK + */ +export const zWebAppAuthGetGroupSubjectsResponse = zGetGroupSubjectsRes + +export const zWebAppAuthIsUserAllowedToAccessWebAppQuery = z.object({ + appId: z.string().optional(), +}) + +/** + * OK + */ +export const zWebAppAuthIsUserAllowedToAccessWebAppResponse = zIsUserAllowedToAccessWebAppRes diff --git a/web/contract/router.ts b/web/contract/router.ts index 086b94f248..4519160d05 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -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 export const consoleRouterContract = { + enterprise: enterpriseContract, account: { avatar: accountAvatarContract, }, diff --git a/web/knip.config.ts b/web/knip.config.ts index d6b71b4af9..1670155aad 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -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', diff --git a/web/openapi-ts.enterprise.config.ts b/web/openapi-ts.enterprise.config.ts new file mode 100644 index 0000000000..3a9086ee3f --- /dev/null +++ b/web/openapi-ts.enterprise.config.ts @@ -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 + +type OpenApiDocument = JsonObject & { + paths?: Record +} + +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', + }, + ], +}) diff --git a/web/package.json b/web/package.json index 3b410a3559..092d82a17e 100644 --- a/web/package.json +++ b/web/package.json @@ -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:", diff --git a/web/plugins/vite/code-inspector.ts b/web/plugins/vite/code-inspector.ts index fe5e3ee769..180e8d37cb 100644 --- a/web/plugins/vite/code-inspector.ts +++ b/web/plugins/vite/code-inspector.ts @@ -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 diff --git a/web/plugins/vite/custom-i18n-hmr.ts b/web/plugins/vite/custom-i18n-hmr.ts index 0e65c5727a..d3e55b4cc4 100644 --- a/web/plugins/vite/custom-i18n-hmr.ts +++ b/web/plugins/vite/custom-i18n-hmr.ts @@ -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 diff --git a/web/plugins/vite/next-static-image-test.ts b/web/plugins/vite/next-static-image-test.ts index cd1bd65c3b..d5323e3312 100644 --- a/web/plugins/vite/next-static-image-test.ts +++ b/web/plugins/vite/next-static-image-test.ts @@ -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 diff --git a/web/tsconfig.json b/web/tsconfig.json index cf4110bbb5..3d6909c79d 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,6 +15,7 @@ "vitest/globals", "node" ], + "allowImportingTsExtensions": true, "allowJs": true }, "include": [ diff --git a/web/vite.config.ts b/web/vite.config.ts index 60c406aa35..3107a8fc60 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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