Merge remote-tracking branch 'origin/feat/agent-v2' into feat/agent-v2

This commit is contained in:
Yanli 盐粒 2026-06-25 14:15:39 +08:00
commit c7bfeeaa80
163 changed files with 2249 additions and 810 deletions

View File

@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
## Using Dify
- **Cloud <br/>**
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan.
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
- **Self-hosting Dify Community Edition<br/>**
Quickly get Dify running in your environment with this [starter guide](#quick-start).

View File

@ -32,6 +32,7 @@ from libs.helper import uuid_value
from models.model import App, AppMode, EndUser
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.conversation_service import ConversationService
from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@ -202,6 +203,12 @@ class ChatApi(WebApiResource):
args["auto_generate_name"] = False
try:
# Eagerly validate conversation to avoid hanging on invalid conversation_id
if payload.conversation_id:
ConversationService.get_conversation(
app_model=app_model, conversation_id=payload.conversation_id, user=end_user
)
response = AppGenerateService.generate(
app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming
)

View File

@ -181,29 +181,33 @@ class TestTencentDataTrace:
mock_trace_utils.convert_to_trace_id.return_value = 123
mock_trace_utils.create_link.return_value = "link"
with patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"):
with patch.object(tencent_data_trace, "_process_workflow_nodes") as mock_proc:
with patch.object(tencent_data_trace, "_record_workflow_trace_duration") as mock_dur:
mock_span_builder.build_workflow_spans.return_value = [MagicMock(), MagicMock()]
with (
patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"),
patch.object(tencent_data_trace, "_process_workflow_nodes") as mock_proc,
patch.object(tencent_data_trace, "_record_workflow_trace_duration") as mock_dur,
):
mock_span_builder.build_workflow_spans.return_value = [MagicMock(), MagicMock()]
tencent_data_trace.workflow_trace(trace_info)
tencent_data_trace.workflow_trace(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("run-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_workflow_spans.assert_called_once()
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_proc.assert_called_once_with(trace_info, 123)
mock_dur.assert_called_once_with(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("run-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_workflow_spans.assert_called_once()
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_proc.assert_called_once_with(trace_info, 123)
mock_dur.assert_called_once_with(trace_info)
def test_workflow_trace_exception(self, tencent_data_trace, caplog):
def test_workflow_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.workflow_run_id = "run-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with caplog.at_level(logging.ERROR):
tencent_data_trace.workflow_trace(trace_info)
tencent_data_trace.workflow_trace(trace_info)
assert "[Tencent APM] Failed to process workflow trace" in caplog.text
def test_message_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
@ -214,28 +218,32 @@ class TestTencentDataTrace:
mock_trace_utils.convert_to_trace_id.return_value = 123
mock_trace_utils.create_link.return_value = "link"
with patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"):
with patch.object(tencent_data_trace, "_record_message_llm_metrics") as mock_metrics:
with patch.object(tencent_data_trace, "_record_message_trace_duration") as mock_dur:
mock_span_builder.build_message_span.return_value = MagicMock()
with (
patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"),
patch.object(tencent_data_trace, "_record_message_llm_metrics") as mock_metrics,
patch.object(tencent_data_trace, "_record_message_trace_duration") as mock_dur,
):
mock_span_builder.build_message_span.return_value = MagicMock()
tencent_data_trace.message_trace(trace_info)
tencent_data_trace.message_trace(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("msg-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_message_span.assert_called_once()
tencent_data_trace.trace_client.add_span.assert_called_once()
mock_metrics.assert_called_once_with(trace_info)
mock_dur.assert_called_once_with(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("msg-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_message_span.assert_called_once()
tencent_data_trace.trace_client.add_span.assert_called_once()
mock_metrics.assert_called_once_with(trace_info)
mock_dur.assert_called_once_with(trace_info)
def test_message_trace_exception(self, tencent_data_trace, caplog):
def test_message_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with caplog.at_level(logging.ERROR):
tencent_data_trace.message_trace(trace_info)
tencent_data_trace.message_trace(trace_info)
assert "[Tencent APM] Failed to process message trace" in caplog.text
def test_tool_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
@ -259,15 +267,17 @@ class TestTencentDataTrace:
tencent_data_trace.tool_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
def test_tool_trace_exception(self, tencent_data_trace, caplog):
def test_tool_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info.message_id = "msg-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with caplog.at_level(logging.ERROR):
tencent_data_trace.tool_trace(trace_info)
tencent_data_trace.tool_trace(trace_info)
assert "[Tencent APM] Failed to process tool trace" in caplog.text
def test_dataset_retrieval_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
@ -291,24 +301,28 @@ class TestTencentDataTrace:
tencent_data_trace.dataset_retrieval_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
def test_dataset_retrieval_trace_exception(self, tencent_data_trace, caplog):
def test_dataset_retrieval_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info.message_id = "msg-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with caplog.at_level(logging.ERROR):
tencent_data_trace.dataset_retrieval_trace(trace_info)
tencent_data_trace.dataset_retrieval_trace(trace_info)
assert "[Tencent APM] Failed to process dataset retrieval trace" in caplog.text
def test_suggested_question_trace(self, tencent_data_trace, caplog):
def test_suggested_question_trace(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
with caplog.at_level(logging.INFO):
tencent_data_trace.suggested_question_trace(trace_info)
assert "[Tencent APM] Processing suggested question trace" in caplog.text
def test_suggested_question_trace_exception(self, tencent_data_trace, monkeypatch, caplog):
def test_suggested_question_trace_exception(
self, tencent_data_trace, monkeypatch, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
target_logger = logging.getLogger("dify_trace_tencent.tencent_trace")
monkeypatch.setattr(target_logger, "info", MagicMock(side_effect=Exception("error")))
@ -328,28 +342,36 @@ class TestTencentDataTrace:
node2.id = "n2"
node2.node_type = BuiltinNodeTypes.TOOL
with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node1, node2]):
with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=["span1", "span2"]):
with patch.object(tencent_data_trace, "_record_llm_metrics") as mock_metrics:
tencent_data_trace._process_workflow_nodes(trace_info, 123)
with (
patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node1, node2]),
patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=["span1", "span2"]),
patch.object(tencent_data_trace, "_record_llm_metrics") as mock_metrics,
):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_metrics.assert_called_once_with(node1)
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_metrics.assert_called_once_with(node1)
def test_process_workflow_nodes_node_exception(self, tencent_data_trace, mock_trace_utils, caplog):
def test_process_workflow_nodes_node_exception(
self, tencent_data_trace, mock_trace_utils, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=WorkflowTraceInfo)
mock_trace_utils.convert_to_span_id.return_value = 111
node = MagicMock(spec=WorkflowNodeExecution)
node.id = "n1"
with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]):
with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")):
with caplog.at_level(logging.ERROR):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
with (
patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]),
patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")),
caplog.at_level(logging.ERROR),
):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
def test_process_workflow_nodes_exception(self, tencent_data_trace, mock_trace_utils, caplog):
def test_process_workflow_nodes_exception(
self, tencent_data_trace, mock_trace_utils, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=WorkflowTraceInfo)
mock_trace_utils.convert_to_span_id.side_effect = Exception("outer error")
@ -377,7 +399,9 @@ class TestTencentDataTrace:
assert result == "span"
builder_method.assert_called_once_with(123, 456, trace_info, node)
def test_build_workflow_node_span_exception(self, tencent_data_trace, mock_span_builder, caplog):
def test_build_workflow_node_span_exception(
self, tencent_data_trace, mock_span_builder, caplog: pytest.LogCaptureFixture
):
node = MagicMock(spec=WorkflowNodeExecution)
node.node_type = BuiltinNodeTypes.LLM
node.id = "n1"
@ -419,7 +443,7 @@ class TestTencentDataTrace:
assert results == mock_executions
account.set_tenant_id.assert_called_once_with("tenant-1")
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace, caplog):
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {}
@ -428,7 +452,7 @@ class TestTencentDataTrace:
assert results == []
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace, caplog):
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {"app_id": "app-1"}
@ -449,13 +473,15 @@ class TestTencentDataTrace:
trace_info.tenant_id = "tenant-1"
trace_info.metadata = {"user_id": "user-1"}
with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("Database error")):
with patch("dify_trace_tencent.tencent_trace.db") as mock_db:
mock_db.init_app = MagicMock()
mock_db.engine = MagicMock()
with (
patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("Database error")),
patch("dify_trace_tencent.tencent_trace.db") as mock_db,
):
mock_db.init_app = MagicMock()
mock_db.engine = MagicMock()
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
def test_get_user_id_only_user_id(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
@ -471,14 +497,16 @@ class TestTencentDataTrace:
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "anonymous"
def test_get_user_id_exception(self, tencent_data_trace, caplog):
def test_get_user_id_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.tenant_id = "t"
trace_info.metadata = {"user_id": "u"}
with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")):
with caplog.at_level(logging.ERROR):
user_id = tencent_data_trace._get_user_id(trace_info)
with (
patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")),
caplog.at_level(logging.ERROR),
):
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
assert "[Tencent APM] Failed to get user ID" in caplog.text
@ -514,7 +542,7 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
tencent_data_trace.trace_client.record_token_usage.assert_called_once()
def test_record_llm_metrics_exception(self, tencent_data_trace, caplog):
def test_record_llm_metrics_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
node = MagicMock(spec=WorkflowNodeExecution)
node.process_data = None
node.outputs = None
@ -553,7 +581,7 @@ class TestTencentDataTrace:
tencent_data_trace._record_message_llm_metrics(trace_info)
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
def test_record_message_llm_metrics_exception(self, tencent_data_trace, caplog):
def test_record_message_llm_metrics_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.metadata = None
@ -605,7 +633,7 @@ class TestTencentDataTrace:
attributes = kwargs["attributes"] if "attributes" in kwargs else args[1] if len(args) > 1 else {}
assert attributes["has_conversation"] == "false"
def test_record_workflow_trace_duration_exception(self, tencent_data_trace, caplog):
def test_record_workflow_trace_duration_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.start_time = MagicMock() # This might cause total_seconds() to fail if not mocked right
@ -627,7 +655,7 @@ class TestTencentDataTrace:
2.0, {"conversation_mode": "chat", "stream": "true"}
)
def test_record_message_trace_duration_exception(self, tencent_data_trace, caplog):
def test_record_message_trace_duration_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.start_time = None
@ -647,7 +675,7 @@ class TestTencentDataTrace:
client.shutdown.assert_called_once()
def test_close_exception(self, tencent_data_trace, caplog):
def test_close_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
tencent_data_trace.trace_client.shutdown.side_effect = Exception("error")
with caplog.at_level(logging.ERROR):
tencent_data_trace.close()

View File

@ -268,9 +268,11 @@ class TestWeaviateVector(unittest.TestCase):
wv._client = MagicMock()
wv._client.collections.exists.side_effect = RuntimeError("create failed")
with patch.object(weaviate_vector_module.logger, "exception") as mock_exception:
with pytest.raises(RuntimeError, match="create failed"):
wv._create_collection()
with (
patch.object(weaviate_vector_module.logger, "exception") as mock_exception,
pytest.raises(RuntimeError, match="create failed"),
):
wv._create_collection()
mock_exception.assert_called_once()
@ -835,9 +837,11 @@ class TestWeaviateVector(unittest.TestCase):
wv._client.collections.use.return_value = mock_col
mock_col.data.delete_by_id.side_effect = FakeUnexpectedStatusCodeError(500)
with patch.object(weaviate_vector_module, "UnexpectedStatusCodeError", FakeUnexpectedStatusCodeError):
with pytest.raises(FakeUnexpectedStatusCodeError, match="status=500"):
wv.delete_by_ids(["bad-id"])
with (
patch.object(weaviate_vector_module, "UnexpectedStatusCodeError", FakeUnexpectedStatusCodeError),
pytest.raises(FakeUnexpectedStatusCodeError, match="status=500"),
):
wv.delete_by_ids(["bad-id"])
def test_json_serializable_converts_datetime(self):
wv = WeaviateVector.__new__(WeaviateVector)

View File

@ -1,5 +1,4 @@
import builtins
from types import SimpleNamespace
from unittest.mock import patch
from flask.views import MethodView as FlaskMethodView
@ -22,7 +21,7 @@ def test_parameters_model_round_trip():
def test_site_icon_url_uses_signed_url_for_image_icon():
site = SimpleNamespace(
site = Site(
title="Example",
chat_color_theme=None,
chat_color_theme_inverted=False,
@ -46,7 +45,7 @@ def test_site_icon_url_uses_signed_url_for_image_icon():
def test_site_icon_url_is_none_for_non_image_icon():
site = SimpleNamespace(
site = Site(
title="Example",
chat_color_theme=None,
chat_color_theme_inverted=False,

View File

@ -2,21 +2,14 @@
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
import pytest
from flask import Flask
from controllers.console.app import workflow as workflow_module
def _unwrap(func):
bound_self = getattr(func, "__self__", None)
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
if bound_self is not None:
return func.__get__(bound_self, bound_self.__class__)
return func
from controllers.console.app.workflow import ConvertToWorkflowApi
class TestConvertToWorkflowApi:
@ -25,9 +18,9 @@ class TestConvertToWorkflowApi:
return workflow_module.ConvertToWorkflowApi()
def test_convert_to_workflow_attaches_permission_keys_when_rbac_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self, api: ConvertToWorkflowApi, app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
method = _unwrap(api.post)
method = unwrap(api.post)
monkeypatch.setattr(
workflow_module,
@ -46,6 +39,7 @@ class TestConvertToWorkflowApi:
json={},
):
response = method(
api,
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id="u1"),
app_model=SimpleNamespace(id="app-1"),

View File

@ -192,7 +192,9 @@ class TestLoginApi:
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog):
def test_login_fails_when_rate_limited(
self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog: pytest.LogCaptureFixture
):
"""
Test login rejection when rate limit is exceeded.
@ -222,7 +224,9 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True)
@patch("controllers.console.auth.login.BillingService.is_email_in_freeze")
def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask, caplog):
def test_login_fails_when_account_frozen(
self, mock_is_frozen, mock_db, app: Flask, caplog: pytest.LogCaptureFixture
):
"""
Test login rejection for frozen accounts.
@ -262,7 +266,7 @@ class TestLoginApi:
mock_is_rate_limit,
mock_db,
app: Flask,
caplog,
caplog: pytest.LogCaptureFixture,
):
"""
Test login failure with invalid credentials.
@ -462,7 +466,7 @@ class TestLoginApi:
mock_get_token_data: MagicMock,
mock_db: MagicMock,
app: Flask,
caplog,
caplog: pytest.LogCaptureFixture,
):
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
mock_get_account.side_effect = Unauthorized("Account is banned.")

View File

@ -1,3 +1,4 @@
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import Mock
@ -10,12 +11,6 @@ from models.account import Account, AccountStatus
from services.workflow_draft_variable_service import WorkflowDraftVariableList
def _unwrap(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
def _make_account() -> Account:
account = Account(
name="tester",
@ -66,7 +61,7 @@ def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable(
def test_conversation_variables_returns_empty_list(app: Flask):
api = module.SnippetConversationVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@ -76,7 +71,7 @@ def test_conversation_variables_returns_empty_list(app: Flask):
def test_system_variables_returns_empty_list(app: Flask):
api = module.SnippetSystemVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@ -91,7 +86,7 @@ def test_delete_variable_collection_deletes_current_user_variables(app: Flask, m
db_session.return_value = SimpleNamespace()
monkeypatch.setattr(module.db, "session", db_session)
api = module.SnippetWorkflowVariableCollectionApi()
handler = _unwrap(api.delete)
handler = unwrap(api.delete)
with app.test_request_context("/", method="DELETE"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@ -109,7 +104,7 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask,
)
api = module.SnippetWorkflowVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/?page=1&limit=20"):
with pytest.raises(module.DraftWorkflowNotExist):
@ -140,7 +135,7 @@ def test_node_variable_collection_get_lists_node_variables(app: Flask, monkeypat
)
api = module.SnippetNodeVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1")
@ -158,7 +153,7 @@ def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monk
monkeypatch.setattr(module.db, "session", db_session)
api = module.SnippetNodeVariableCollectionApi()
handler = _unwrap(api.delete)
handler = unwrap(api.delete)
with app.test_request_context("/", method="DELETE"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1")
@ -177,7 +172,7 @@ def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
api = module.SnippetVariableApi()
handler = _unwrap(api.patch)
handler = unwrap(api.patch)
with app.test_request_context("/", method="PATCH", json={}):
result = handler(
@ -202,7 +197,7 @@ def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.Monkey
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
api = module.SnippetVariableApi()
handler = _unwrap(api.delete)
handler = unwrap(api.delete)
with app.test_request_context("/", method="DELETE"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1")
@ -230,7 +225,7 @@ def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask,
)
api = module.SnippetVariableResetApi()
handler = _unwrap(api.put)
handler = unwrap(api.put)
with app.test_request_context("/", method="PUT"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1")
@ -260,7 +255,7 @@ def test_environment_variables_returns_workflow_environment_variables(app: Flask
)
api = module.SnippetEnvironmentVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))

View File

@ -21,6 +21,7 @@ from core.ops.entities.trace_entity import (
WorkflowNodeTraceInfo,
WorkflowTraceInfo,
)
from enterprise.telemetry.enterprise_trace import EnterpriseOtelTrace
from enterprise.telemetry.entities import (
EnterpriseTelemetryCounter,
EnterpriseTelemetryEvent,
@ -297,43 +298,43 @@ def test_init_succeeds_with_valid_exporter(mock_exporter):
class TestSafePayloadValue:
def test_string_passthrough(self, trace_handler):
def test_string_passthrough(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value("hello") == "hello"
def test_dict_passthrough(self, trace_handler):
def test_dict_passthrough(self, trace_handler: EnterpriseOtelTrace):
d = {"key": "val"}
assert trace_handler._safe_payload_value(d) == d
def test_list_passthrough(self, trace_handler):
def test_list_passthrough(self, trace_handler: EnterpriseOtelTrace):
lst = [1, 2, 3]
assert trace_handler._safe_payload_value(lst) == lst
def test_none_returns_none(self, trace_handler):
def test_none_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value(None) is None
def test_int_returns_none(self, trace_handler):
def test_int_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value(42) is None
def test_bool_returns_none(self, trace_handler):
def test_bool_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value(True) is None
class TestMaybeJson:
def test_none_returns_none(self, trace_handler):
def test_none_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._maybe_json(None) is None
def test_string_passthrough(self, trace_handler):
def test_string_passthrough(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._maybe_json("hello") == "hello"
def test_dict_serialised(self, trace_handler):
def test_dict_serialised(self, trace_handler: EnterpriseOtelTrace):
result = trace_handler._maybe_json({"a": 1})
assert result == json.dumps({"a": 1})
def test_list_serialised(self, trace_handler):
def test_list_serialised(self, trace_handler: EnterpriseOtelTrace):
result = trace_handler._maybe_json([1, 2])
assert result == "[1, 2]"
def test_non_serialisable_falls_back_to_str(self, trace_handler):
def test_non_serialisable_falls_back_to_str(self, trace_handler: EnterpriseOtelTrace):
class Unserializable:
def __repr__(self):
return "Unserializable()"
@ -344,22 +345,22 @@ class TestMaybeJson:
class TestContentOrRef:
def test_returns_content_when_include_content_true(self, trace_handler, mock_exporter):
def test_returns_content_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
result = trace_handler._content_or_ref("actual content", "ref:x=1")
assert result == "actual content"
def test_returns_ref_when_include_content_false(self, trace_handler, mock_exporter):
def test_returns_ref_when_include_content_false(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
result = trace_handler._content_or_ref("actual content", "ref:x=1")
assert result == "ref:x=1"
def test_dict_serialised_when_include_content_true(self, trace_handler, mock_exporter):
def test_dict_serialised_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
result = trace_handler._content_or_ref({"key": "val"}, "ref:x=1")
assert result == json.dumps({"key": "val"})
def test_none_returns_none_when_include_content_true(self, trace_handler, mock_exporter):
def test_none_returns_none_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
result = trace_handler._content_or_ref(None, "ref:x=1")
assert result is None
@ -371,67 +372,67 @@ class TestContentOrRef:
class TestTraceDispatcher:
def test_dispatches_workflow_trace(self, trace_handler):
def test_dispatches_workflow_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_workflow_trace") as mock_method:
info = make_workflow_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_message_trace(self, trace_handler):
def test_dispatches_message_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_message_trace") as mock_method:
info = make_message_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_tool_trace(self, trace_handler):
def test_dispatches_tool_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_tool_trace") as mock_method:
info = make_tool_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_draft_node_execution_trace(self, trace_handler):
def test_dispatches_draft_node_execution_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_draft_node_execution_trace") as mock_method:
info = make_draft_node_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_node_execution_trace(self, trace_handler):
def test_dispatches_node_execution_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_node_execution_trace") as mock_method:
info = make_node_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_moderation_trace(self, trace_handler):
def test_dispatches_moderation_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_moderation_trace") as mock_method:
info = make_moderation_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_suggested_question_trace(self, trace_handler):
def test_dispatches_suggested_question_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_suggested_question_trace") as mock_method:
info = make_suggested_question_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_dataset_retrieval_trace(self, trace_handler):
def test_dispatches_dataset_retrieval_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_dataset_retrieval_trace") as mock_method:
info = make_dataset_retrieval_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_generate_name_trace(self, trace_handler):
def test_dispatches_generate_name_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_generate_name_trace") as mock_method:
info = make_generate_name_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_prompt_generation_trace(self, trace_handler):
def test_dispatches_prompt_generation_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_prompt_generation_trace") as mock_method:
info = make_prompt_generation_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_draft_node_dispatched_before_node(self, trace_handler):
def test_draft_node_dispatched_before_node(self, trace_handler: EnterpriseOtelTrace):
"""DraftNodeExecutionTrace is a subclass of WorkflowNodeTraceInfo;
it must be dispatched to _draft_node_execution_trace, not _node_execution_trace."""
with (
@ -450,7 +451,7 @@ class TestTraceDispatcher:
class TestWorkflowTrace:
def test_emits_correct_span_attributes(self, trace_handler, mock_exporter):
def test_emits_correct_span_attributes(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
info = make_workflow_info()
trace_handler._workflow_trace(info)
@ -465,7 +466,7 @@ class TestWorkflowTrace:
assert attrs["dify.workflow.status"] == "succeeded"
assert attrs["gen_ai.usage.total_tokens"] == 100
def test_span_timing_passed_correctly(self, trace_handler, mock_exporter):
def test_span_timing_passed_correctly(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info()
trace_handler._workflow_trace(info)
@ -474,7 +475,7 @@ class TestWorkflowTrace:
assert span_call[1]["start_time"] == _T0
assert span_call[1]["end_time"] == _T1
def test_emits_companion_log_with_event_name(self, trace_handler, mock_exporter):
def test_emits_companion_log_with_event_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._workflow_trace(make_workflow_info())
@ -482,7 +483,7 @@ class TestWorkflowTrace:
assert mock_log.call_args[1]["event_name"] == EnterpriseTelemetryEvent.WORKFLOW_RUN
assert mock_log.call_args[1]["tenant_id"] == "tenant-abc"
def test_companion_log_includes_content_when_enabled(self, trace_handler, mock_exporter):
def test_companion_log_includes_content_when_enabled(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._workflow_trace(make_workflow_info())
@ -491,7 +492,7 @@ class TestWorkflowTrace:
assert log_attrs["dify.workflow.inputs"] == json.dumps({"query": "hello"})
assert log_attrs["dify.workflow.outputs"] == json.dumps({"answer": "world"})
def test_companion_log_uses_ref_when_content_disabled(self, trace_handler, mock_exporter):
def test_companion_log_uses_ref_when_content_disabled(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._workflow_trace(make_workflow_info())
@ -500,7 +501,7 @@ class TestWorkflowTrace:
assert log_attrs["dify.workflow.inputs"].startswith("ref:workflow_run_id=")
assert log_attrs["dify.workflow.outputs"].startswith("ref:workflow_run_id=")
def test_increments_token_counter(self, trace_handler, mock_exporter):
def test_increments_token_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@ -510,7 +511,7 @@ class TestWorkflowTrace:
assert len(token_calls) == 1
assert token_calls[0][0][1] == 100
def test_increments_input_and_output_token_counters(self, trace_handler, mock_exporter):
def test_increments_input_and_output_token_counters(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@ -519,7 +520,7 @@ class TestWorkflowTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_no_input_token_counter_when_prompt_tokens_zero(self, trace_handler, mock_exporter):
def test_no_input_token_counter_when_prompt_tokens_zero(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(prompt_tokens=0)
trace_handler._workflow_trace(info)
@ -528,7 +529,7 @@ class TestWorkflowTrace:
counter_names = [c[0][0] for c in all_calls]
assert EnterpriseTelemetryCounter.INPUT_TOKENS not in counter_names
def test_records_workflow_duration_histogram(self, trace_handler, mock_exporter):
def test_records_workflow_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@ -537,7 +538,9 @@ class TestWorkflowTrace:
assert hist_call[0][0] == EnterpriseTelemetryHistogram.WORKFLOW_DURATION
assert hist_call[0][1] == pytest.approx(5.0)
def test_duration_falls_back_to_elapsed_time_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_duration_falls_back_to_elapsed_time_when_timestamps_missing(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(start_time=None, end_time=None, workflow_run_elapsed_time=7.3)
trace_handler._workflow_trace(info)
@ -545,7 +548,7 @@ class TestWorkflowTrace:
hist_call = mock_exporter.record_histogram.call_args
assert hist_call[0][1] == pytest.approx(7.3)
def test_duration_defaults_to_zero_when_no_timing(self, trace_handler, mock_exporter):
def test_duration_defaults_to_zero_when_no_timing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(start_time=None, end_time=None, workflow_run_elapsed_time=0)
trace_handler._workflow_trace(info)
@ -553,7 +556,7 @@ class TestWorkflowTrace:
hist_call = mock_exporter.record_histogram.call_args
assert hist_call[0][1] == pytest.approx(0.0)
def test_error_path_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_path_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(error="Something went wrong", workflow_run_status="failed")
trace_handler._workflow_trace(info)
@ -563,7 +566,7 @@ class TestWorkflowTrace:
]
assert len(error_calls) == 1
def test_no_error_counter_on_success(self, trace_handler, mock_exporter):
def test_no_error_counter_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@ -572,7 +575,7 @@ class TestWorkflowTrace:
]
assert len(error_calls) == 0
def test_parent_trace_context_injected_into_span_attrs(self, trace_handler, mock_exporter):
def test_parent_trace_context_injected_into_span_attrs(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(
metadata={
@ -601,14 +604,14 @@ class TestWorkflowTrace:
class TestNodeExecutionTrace:
def test_emits_span_with_node_execution_span_name(self, trace_handler, mock_exporter):
def test_emits_span_with_node_execution_span_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
span_call = mock_exporter.export_span.call_args
assert span_call[0][0] == EnterpriseTelemetrySpan.NODE_EXECUTION
def test_span_contains_core_node_attributes(self, trace_handler, mock_exporter):
def test_span_contains_core_node_attributes(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
@ -620,7 +623,7 @@ class TestNodeExecutionTrace:
assert attrs["gen_ai.request.model"] == "gpt-4"
assert attrs["gen_ai.provider.name"] == "openai"
def test_increments_token_counters_when_tokens_present(self, trace_handler, mock_exporter):
def test_increments_token_counters_when_tokens_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
@ -629,7 +632,7 @@ class TestNodeExecutionTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_no_token_counters_when_total_tokens_zero(self, trace_handler, mock_exporter):
def test_no_token_counters_when_total_tokens_zero(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info(total_tokens=0))
@ -637,7 +640,7 @@ class TestNodeExecutionTrace:
assert EnterpriseTelemetryCounter.TOKENS not in counter_names
assert EnterpriseTelemetryCounter.INPUT_TOKENS not in counter_names
def test_records_node_duration_histogram(self, trace_handler, mock_exporter):
def test_records_node_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
@ -645,7 +648,7 @@ class TestNodeExecutionTrace:
assert hist_call[0][0] == EnterpriseTelemetryHistogram.NODE_DURATION
assert hist_call[0][1] == pytest.approx(2.5)
def test_error_path_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_path_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info(error="Node failed", status="failed"))
@ -654,14 +657,16 @@ class TestNodeExecutionTrace:
]
assert len(error_calls) == 1
def test_emits_companion_log_with_span_name_as_event(self, trace_handler, mock_exporter):
def test_emits_companion_log_with_span_name_as_event(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._node_execution_trace(make_node_info())
mock_log.assert_called_once()
assert mock_log.call_args[1]["event_name"] == EnterpriseTelemetrySpan.NODE_EXECUTION.value
def test_plugin_name_added_to_duration_labels_for_tool_node(self, trace_handler, mock_exporter):
def test_plugin_name_added_to_duration_labels_for_tool_node(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_node_info(
node_type="tool",
@ -677,7 +682,7 @@ class TestNodeExecutionTrace:
duration_labels = hist_call[0][2]
assert duration_labels.get("plugin_name") == "my-plugin"
def test_plugin_name_not_added_for_non_tool_node(self, trace_handler, mock_exporter):
def test_plugin_name_not_added_for_non_tool_node(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_node_info(
node_type="llm",
@ -693,7 +698,9 @@ class TestNodeExecutionTrace:
duration_labels = hist_call[0][2]
assert "plugin_name" not in duration_labels
def test_companion_log_inputs_use_ref_when_content_disabled(self, trace_handler, mock_exporter):
def test_companion_log_inputs_use_ref_when_content_disabled(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._node_execution_trace(
@ -711,14 +718,14 @@ class TestNodeExecutionTrace:
class TestDraftNodeExecutionTrace:
def test_uses_draft_span_name(self, trace_handler, mock_exporter):
def test_uses_draft_span_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._draft_node_execution_trace(make_draft_node_info())
span_call = mock_exporter.export_span.call_args
assert span_call[0][0] == EnterpriseTelemetrySpan.DRAFT_NODE_EXECUTION
def test_correlation_id_is_node_execution_id(self, trace_handler, mock_exporter):
def test_correlation_id_is_node_execution_id(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_draft_node_info()
trace_handler._draft_node_execution_trace(info)
@ -726,7 +733,7 @@ class TestDraftNodeExecutionTrace:
span_call = mock_exporter.export_span.call_args
assert span_call[1]["correlation_id"] == "ne-draft-001"
def test_trace_correlation_override_is_workflow_run_id(self, trace_handler, mock_exporter):
def test_trace_correlation_override_is_workflow_run_id(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_draft_node_info()
trace_handler._draft_node_execution_trace(info)
@ -734,7 +741,7 @@ class TestDraftNodeExecutionTrace:
span_call = mock_exporter.export_span.call_args
assert span_call[1]["trace_correlation_override"] == "run-draft-001"
def test_companion_log_uses_draft_span_name(self, trace_handler, mock_exporter):
def test_companion_log_uses_draft_span_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._draft_node_execution_trace(make_draft_node_info())
@ -747,34 +754,36 @@ class TestDraftNodeExecutionTrace:
class TestMessageTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
mock_emit.assert_called_once()
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.MESSAGE_RUN
def test_emits_correct_tenant_and_user(self, trace_handler, mock_exporter):
def test_emits_correct_tenant_and_user(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
assert mock_emit.call_args[1]["tenant_id"] == "tenant-abc"
def test_duration_computed_from_timestamps(self, trace_handler, mock_exporter):
def test_duration_computed_from_timestamps(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.message.duration"] == pytest.approx(5.0)
def test_no_duration_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_no_duration_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info(start_time=None, end_time=None))
attrs = mock_emit.call_args[1]["attributes"]
assert "dify.message.duration" not in attrs
def test_records_duration_histogram_when_timestamps_present(self, trace_handler, mock_exporter):
def test_records_duration_histogram_when_timestamps_present(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info())
@ -786,14 +795,14 @@ class TestMessageTrace:
assert len(hist_calls) == 1
assert hist_calls[0][0][1] == pytest.approx(5.0)
def test_no_duration_histogram_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_no_duration_histogram_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(start_time=None, end_time=None))
hist_names = [c[0][0] for c in mock_exporter.record_histogram.call_args_list]
assert EnterpriseTelemetryHistogram.MESSAGE_DURATION not in hist_names
def test_records_ttft_histogram_when_present(self, trace_handler, mock_exporter):
def test_records_ttft_histogram_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(gen_ai_server_time_to_first_token=0.42))
@ -805,14 +814,14 @@ class TestMessageTrace:
assert len(ttft_calls) == 1
assert ttft_calls[0][0][1] == pytest.approx(0.42)
def test_no_ttft_histogram_when_not_present(self, trace_handler, mock_exporter):
def test_no_ttft_histogram_when_not_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(gen_ai_server_time_to_first_token=None))
hist_names = [c[0][0] for c in mock_exporter.record_histogram.call_args_list]
assert EnterpriseTelemetryHistogram.MESSAGE_TTFT not in hist_names
def test_increments_token_counters(self, trace_handler, mock_exporter):
def test_increments_token_counters(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info())
@ -821,7 +830,7 @@ class TestMessageTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_error_path_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_path_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(error="LLM failed"))
@ -830,7 +839,7 @@ class TestMessageTrace:
]
assert len(error_calls) == 1
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler, mock_exporter):
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
@ -846,27 +855,27 @@ class TestMessageTrace:
class TestToolTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.TOOL_EXECUTION
def test_status_is_succeeded_on_success(self, trace_handler, mock_exporter):
def test_status_is_succeeded_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.tool.status"] == "succeeded"
def test_status_is_failed_on_error(self, trace_handler, mock_exporter):
def test_status_is_failed_on_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info(error="Tool error"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.tool.status"] == "failed"
def test_records_tool_duration_histogram(self, trace_handler, mock_exporter):
def test_records_tool_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._tool_trace(make_tool_info())
@ -874,7 +883,7 @@ class TestToolTrace:
assert hist_call[0][0] == EnterpriseTelemetryHistogram.TOOL_DURATION
assert hist_call[0][1] == pytest.approx(1.5)
def test_error_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._tool_trace(make_tool_info(error="Tool crashed"))
@ -883,7 +892,7 @@ class TestToolTrace:
]
assert len(error_calls) == 1
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler, mock_exporter):
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
@ -892,7 +901,7 @@ class TestToolTrace:
assert attrs["dify.tool.inputs"].startswith("ref:message_id=")
assert attrs["dify.tool.outputs"].startswith("ref:message_id=")
def test_inputs_present_when_include_content_true(self, trace_handler, mock_exporter):
def test_inputs_present_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
@ -901,7 +910,7 @@ class TestToolTrace:
assert attrs["dify.tool.inputs"] == json.dumps({"query": "test"})
assert attrs["dify.tool.outputs"] == "search results"
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._tool_trace(make_tool_info())
@ -918,27 +927,27 @@ class TestToolTrace:
class TestModerationTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.MODERATION_CHECK
def test_flagged_true_sets_attribute(self, trace_handler, mock_exporter):
def test_flagged_true_sets_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info(flagged=True))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.flagged"] is True
def test_flagged_false_sets_attribute(self, trace_handler, mock_exporter):
def test_flagged_false_sets_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info(flagged=False))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.flagged"] is False
def test_query_gated_by_include_content(self, trace_handler, mock_exporter):
def test_query_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info())
@ -946,7 +955,7 @@ class TestModerationTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.query"].startswith("ref:message_id=")
def test_query_present_when_include_content_true(self, trace_handler, mock_exporter):
def test_query_present_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info())
@ -954,7 +963,7 @@ class TestModerationTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.query"] == "is this ok?"
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._moderation_trace(make_moderation_info())
@ -971,48 +980,48 @@ class TestModerationTrace:
class TestSuggestedQuestionTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.SUGGESTED_QUESTION_GENERATION
def test_duration_computed_from_timestamps(self, trace_handler, mock_exporter):
def test_duration_computed_from_timestamps(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.duration"] == pytest.approx(5.0)
def test_duration_is_none_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_duration_is_none_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info(start_time=None, end_time=None))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.duration"] is None
def test_status_is_failed_when_error_present(self, trace_handler, mock_exporter):
def test_status_is_failed_when_error_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info(error="Generation failed"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.status"] == "failed"
def test_status_falls_back_to_succeeded_when_no_error(self, trace_handler, mock_exporter):
def test_status_falls_back_to_succeeded_when_no_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info(status=None, error=None))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.status"] == "succeeded"
def test_question_count_attribute(self, trace_handler, mock_exporter):
def test_question_count_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.count"] == 2
def test_questions_gated_by_include_content(self, trace_handler, mock_exporter):
def test_questions_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
@ -1020,7 +1029,7 @@ class TestSuggestedQuestionTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.questions"].startswith("ref:message_id=")
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._suggested_question_trace(make_suggested_question_info())
@ -1037,48 +1046,48 @@ class TestSuggestedQuestionTrace:
class TestDatasetRetrievalTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.DATASET_RETRIEVAL
def test_document_count_attribute(self, trace_handler, mock_exporter):
def test_document_count_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.document_count"] == 1
def test_dataset_ids_extracted(self, trace_handler, mock_exporter):
def test_dataset_ids_extracted(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
attrs = mock_emit.call_args[1]["attributes"]
assert "ds-001" in attrs["dify.dataset.id"]
def test_empty_documents_has_zero_count(self, trace_handler, mock_exporter):
def test_empty_documents_has_zero_count(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info(documents=[]))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.document_count"] == 0
def test_status_succeeded_when_no_error(self, trace_handler, mock_exporter):
def test_status_succeeded_when_no_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.status"] == "succeeded"
def test_status_failed_when_error_present(self, trace_handler, mock_exporter):
def test_status_failed_when_error_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info(error="DB error"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.status"] == "failed"
def test_embedding_model_attributes_set_when_present(self, trace_handler, mock_exporter):
def test_embedding_model_attributes_set_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
@ -1086,7 +1095,7 @@ class TestDatasetRetrievalTrace:
assert "dify.dataset.embedding_providers" in attrs
assert "dify.dataset.embedding_models" in attrs
def test_no_embedding_model_attributes_when_not_provided(self, trace_handler, mock_exporter):
def test_no_embedding_model_attributes_when_not_provided(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(
make_dataset_retrieval_info(metadata={"app_id": "app-001", "tenant_id": "tenant-abc"})
@ -1096,7 +1105,7 @@ class TestDatasetRetrievalTrace:
assert "dify.dataset.embedding_providers" not in attrs
assert "dify.dataset.embedding_models" not in attrs
def test_rerank_attributes_set_when_present(self, trace_handler, mock_exporter):
def test_rerank_attributes_set_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(
make_dataset_retrieval_info(
@ -1113,7 +1122,7 @@ class TestDatasetRetrievalTrace:
assert attrs["dify.retrieval.rerank_provider"] == "cohere"
assert attrs["dify.retrieval.rerank_model"] == "rerank-english"
def test_no_rerank_attributes_when_not_present(self, trace_handler, mock_exporter):
def test_no_rerank_attributes_when_not_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(
make_dataset_retrieval_info(metadata={"app_id": "app-001", "tenant_id": "tenant-abc"})
@ -1123,7 +1132,7 @@ class TestDatasetRetrievalTrace:
assert "dify.retrieval.rerank_provider" not in attrs
assert "dify.retrieval.rerank_model" not in attrs
def test_dataset_retrieval_counter_incremented_per_dataset(self, trace_handler, mock_exporter):
def test_dataset_retrieval_counter_incremented_per_dataset(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
@ -1135,7 +1144,7 @@ class TestDatasetRetrievalTrace:
assert len(ds_calls) == 1
assert ds_calls[0][0][2]["dataset_id"] == "ds-001"
def test_no_dataset_retrieval_counter_when_no_documents(self, trace_handler, mock_exporter):
def test_no_dataset_retrieval_counter_when_no_documents(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info(documents=[]))
@ -1146,7 +1155,7 @@ class TestDatasetRetrievalTrace:
]
assert len(ds_calls) == 0
def test_query_gated_by_include_content(self, trace_handler, mock_exporter):
def test_query_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
@ -1161,34 +1170,34 @@ class TestDatasetRetrievalTrace:
class TestGenerateNameTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.GENERATE_NAME_EXECUTION
def test_duration_computed_from_timestamps(self, trace_handler, mock_exporter):
def test_duration_computed_from_timestamps(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.duration"] == pytest.approx(5.0)
def test_no_duration_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_no_duration_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info(start_time=None, end_time=None))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.duration"] is None
def test_status_succeeded_on_success(self, trace_handler, mock_exporter):
def test_status_succeeded_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.status"] == "succeeded"
def test_status_failed_when_metadata_has_error(self, trace_handler, mock_exporter):
def test_status_failed_when_metadata_has_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(
make_generate_name_info(
@ -1203,7 +1212,7 @@ class TestGenerateNameTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.status"] == "failed"
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler, mock_exporter):
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
@ -1212,7 +1221,7 @@ class TestGenerateNameTrace:
assert attrs["dify.generate_name.inputs"].startswith("ref:conversation_id=")
assert attrs["dify.generate_name.outputs"].startswith("ref:conversation_id=")
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._generate_name_trace(make_generate_name_info())
@ -1229,27 +1238,27 @@ class TestGenerateNameTrace:
class TestPromptGenerationTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.PROMPT_GENERATION_EXECUTION
def test_status_succeeded_on_success(self, trace_handler, mock_exporter):
def test_status_succeeded_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.prompt_generation.status"] == "succeeded"
def test_status_failed_when_error_present(self, trace_handler, mock_exporter):
def test_status_failed_when_error_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info(error="Generation error"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.prompt_generation.status"] == "failed"
def test_token_counters_incremented(self, trace_handler, mock_exporter):
def test_token_counters_incremented(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@ -1258,7 +1267,7 @@ class TestPromptGenerationTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_records_duration_histogram(self, trace_handler, mock_exporter):
def test_records_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@ -1270,7 +1279,7 @@ class TestPromptGenerationTrace:
assert len(hist_calls) == 1
assert hist_calls[0][0][1] == pytest.approx(3.2)
def test_total_price_attribute_set_when_present(self, trace_handler, mock_exporter):
def test_total_price_attribute_set_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info(total_price=0.05, currency="USD"))
@ -1278,14 +1287,14 @@ class TestPromptGenerationTrace:
assert attrs["dify.prompt_generation.total_price"] == pytest.approx(0.05)
assert attrs["dify.prompt_generation.currency"] == "USD"
def test_no_total_price_attribute_when_none(self, trace_handler, mock_exporter):
def test_no_total_price_attribute_when_none(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info(total_price=None))
attrs = mock_emit.call_args[1]["attributes"]
assert "dify.prompt_generation.total_price" not in attrs
def test_error_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info(error="Prompt failed"))
@ -1294,7 +1303,7 @@ class TestPromptGenerationTrace:
]
assert len(error_calls) == 1
def test_no_error_counter_on_success(self, trace_handler, mock_exporter):
def test_no_error_counter_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@ -1303,7 +1312,7 @@ class TestPromptGenerationTrace:
]
assert len(error_calls) == 0
def test_instruction_gated_by_include_content(self, trace_handler, mock_exporter):
def test_instruction_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@ -1311,7 +1320,7 @@ class TestPromptGenerationTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.prompt_generation.instruction"].startswith("ref:trace_id=")
def test_operation_type_label_used_in_token_counters(self, trace_handler, mock_exporter):
def test_operation_type_label_used_in_token_counters(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info(operation_type="code_generate"))
@ -1321,7 +1330,7 @@ class TestPromptGenerationTrace:
assert len(token_calls) == 1
assert token_calls[0][0][2]["operation_type"] == "code_generate"
def test_emits_correct_tenant_id(self, trace_handler, mock_exporter):
def test_emits_correct_tenant_id(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())

1
api/uv.lock generated
View File

@ -1299,6 +1299,7 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
{ name = "protobuf", marker = "extra == 'grpc'", specifier = ">=6.33.5,<7.0.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" },

View File

@ -4444,11 +4444,6 @@
"count": 1
}
},
"web/app/components/tools/provider/detail.tsx": {
"jsx-a11y/anchor-has-content": {
"count": 1
}
},
"web/app/components/tools/provider/tool-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.66661 1.33333C9.8434 1.33335 10.013 1.40364 10.138 1.52865L11.4713 2.86198C11.5963 2.987 11.6666 3.15654 11.6666 3.33333C11.6666 3.51012 11.5963 3.67967 11.4713 3.80469L9.10932 6.16667L14.3046 11.362C14.5649 11.6223 14.5649 12.0443 14.3046 12.3047L12.3046 14.3047C12.0443 14.565 11.6223 14.565 11.3619 14.3047L6.16661 9.10938L4.4713 10.8047C4.3463 10.9297 4.17673 11 3.99995 11C3.82316 11 3.65361 10.9297 3.52859 10.8047L0.695261 7.97136C0.434913 7.71101 0.434913 7.289 0.695261 7.02865L6.19526 1.52865L6.24409 1.48438C6.36272 1.38718 6.51191 1.33333 6.66661 1.33333H9.66661ZM7.10932 8.16667L11.8333 12.8906L12.8906 11.8333L8.16661 7.10938L7.10932 8.16667ZM2.10932 7.5L3.99995 9.39063L10.0572 3.33333L9.39057 2.66667H6.94266L2.10932 7.5Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 919 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15" fill="none">
<path d="M12.4756 4.75207L12.3112 5.12919C12.1909 5.40528 11.8091 5.40528 11.6887 5.12919L11.5244 4.75207C11.2314 4.07965 10.7037 3.54427 10.0451 3.25139L9.53867 3.02615C9.26487 2.90435 9.26487 2.50587 9.53867 2.38408L10.0168 2.17143C10.6923 1.87101 11.2295 1.31582 11.5174 0.620554L11.6862 0.21302C11.8039 -0.0710068 12.1961 -0.0710068 12.3137 0.21302L12.4825 0.620554C12.7705 1.31582 13.3077 1.87101 13.9832 2.17143L14.4613 2.38408C14.7351 2.50587 14.7351 2.90435 14.4613 3.02615L13.9549 3.25139C13.2963 3.54427 12.7686 4.07965 12.4756 4.75207ZM5.33333 1.33333H8V2.66667H5.33333C3.12419 2.66667 1.33333 4.45753 1.33333 6.66667C1.33333 9.07333 2.97472 10.6437 6.66667 12.3199V10.6667H8C10.2091 10.6667 12 8.8758 12 6.66667H13.3333C13.3333 9.6122 10.9455 12 8 12V14.3333C4.66667 13 0 11 0 6.66667C0 3.72115 2.38781 1.33333 5.33333 1.33333Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 969 B

View File

@ -1,6 +1,6 @@
{
"prefix": "custom-vender",
"lastModified": 1782215796,
"lastModified": 1782365697,
"icons": {
"agent-v2-access-point": {
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
@ -15,6 +15,15 @@
"body": "<g fill=\"none\"><path d=\"M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM6.75 12.75C8.82105 12.75 10.5 11.071 10.5 9C10.5 6.92895 8.82105 5.25 6.75 5.25C4.67893 5.25 3 6.92895 3 9C3 11.071 4.67893 12.75 6.75 12.75ZM6.75 11.25C5.50732 11.25 4.5 10.2427 4.5 9C4.5 7.75732 5.50732 6.75 6.75 6.75C7.99268 6.75 9 7.75732 9 9C9 10.2427 7.99268 11.25 6.75 11.25ZM6.75 9.75C7.16422 9.75 7.5 9.41422 7.5 9C7.5 8.58578 7.16422 8.25 6.75 8.25C6.33578 8.25 6 8.58578 6 9C6 9.41422 6.33578 9.75 6.75 9.75Z\" fill=\"currentColor\"/></g>",
"width": 14
},
"agent-v2-configure-build": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M9.66661 1.33333C9.8434 1.33335 10.013 1.40364 10.138 1.52865L11.4713 2.86198C11.5963 2.987 11.6666 3.15654 11.6666 3.33333C11.6666 3.51012 11.5963 3.67967 11.4713 3.80469L9.10932 6.16667L14.3046 11.362C14.5649 11.6223 14.5649 12.0443 14.3046 12.3047L12.3046 14.3047C12.0443 14.565 11.6223 14.565 11.3619 14.3047L6.16661 9.10938L4.4713 10.8047C4.3463 10.9297 4.17673 11 3.99995 11C3.82316 11 3.65361 10.9297 3.52859 10.8047L0.695261 7.97136C0.434913 7.71101 0.434913 7.289 0.695261 7.02865L6.19526 1.52865L6.24409 1.48438C6.36272 1.38718 6.51191 1.33333 6.66661 1.33333H9.66661ZM7.10932 8.16667L11.8333 12.8906L12.8906 11.8333L8.16661 7.10938L7.10932 8.16667ZM2.10932 7.5L3.99995 9.39063L10.0572 3.33333L9.39057 2.66667H6.94266L2.10932 7.5Z\" fill=\"currentColor\"/></g>",
"width": 16
},
"agent-v2-configure-preview": {
"body": "<g fill=\"none\"><path d=\"M12.4756 4.75207L12.3112 5.12919C12.1909 5.40528 11.8091 5.40528 11.6887 5.12919L11.5244 4.75207C11.2314 4.07965 10.7037 3.54427 10.0451 3.25139L9.53867 3.02615C9.26487 2.90435 9.26487 2.50587 9.53867 2.38408L10.0168 2.17143C10.6923 1.87101 11.2295 1.31582 11.5174 0.620554L11.6862 0.21302C11.8039-0.0710068 12.1961-0.0710068 12.3137 0.21302L12.4825 0.620554C12.7705 1.31582 13.3077 1.87101 13.9832 2.17143L14.4613 2.38408C14.7351 2.50587 14.7351 2.90435 14.4613 3.02615L13.9549 3.25139C13.2963 3.54427 12.7686 4.07965 12.4756 4.75207ZM5.33333 1.33333H8V2.66667H5.33333C3.12419 2.66667 1.33333 4.45753 1.33333 6.66667C1.33333 9.07333 2.97472 10.6437 6.66667 12.3199V10.6667H8C10.2091 10.6667 12 8.8758 12 6.66667H13.3333C13.3333 9.6122 10.9455 12 8 12V14.3333C4.66667 13 0 11 0 6.66667C0 3.72115 2.38781 1.33333 5.33333 1.33333Z\" fill=\"currentColor\"/></g>",
"width": 15,
"height": 15
},
"agent-v2-end-user-auth": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12 7.33325C13.1046 7.33325 14 8.22865 14 9.33325C14 10.1403 13.5218 10.8356 12.8333 11.1516V11.9999L12.3333 12.4999L12.8333 12.9511V13.6666L12 14.3333L11.1667 13.6666V11.1516C10.4782 10.8356 10 10.1403 10 9.33325C10 8.22865 10.8954 7.33325 12 7.33325ZM12 8.66659C11.6318 8.66659 11.3333 8.96505 11.3333 9.33325C11.3333 9.70145 11.6318 9.99992 12 9.99992C12.3682 9.99992 12.6667 9.70145 12.6667 9.33325C12.6667 8.96505 12.3682 8.66659 12 8.66659Z\" fill=\"currentColor\"/><path d=\"M8 7.99992C8.2545 7.99992 8.50382 8.01506 8.7474 8.04484L8.58594 9.36841C8.39687 9.34527 8.20127 9.33325 8 9.33325C5.8465 9.33325 4.25915 10.7274 3.78646 12.6666H10V13.9999H2.26758L2.33594 13.2708C2.61081 10.3473 4.82817 7.99992 8 7.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8 1.33325C9.65687 1.33325 11 2.6764 11 4.33325C11 5.99011 9.65687 7.33325 8 7.33325C6.34315 7.33325 5 5.99011 5 4.33325C5 2.6764 6.34315 1.33325 8 1.33325ZM8 2.66659C7.07953 2.66659 6.33333 3.41278 6.33333 4.33325C6.33333 5.25373 7.07953 5.99992 8 5.99992C8.92047 5.99992 9.66667 5.25373 9.66667 4.33325C9.66667 3.41278 8.92047 2.66659 8 2.66659Z\" fill=\"currentColor\"/></g>",
"width": 16

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 328,
"total": 330,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",
@ -16,9 +16,9 @@
"agent-v2-access-point",
"agent-v2-configure",
"agent-v2-configure-active",
"agent-v2-end-user-auth",
"agent-v2-plan",
"agent-v2-prompt-insert"
"agent-v2-configure-build",
"agent-v2-configure-preview",
"agent-v2-end-user-auth"
],
"palette": false
}

View File

@ -5,7 +5,7 @@ import { AppPublisher } from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockFetchAppDetailDirect = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockSetAppDetail = vi.fn()
const mockRefetch = vi.fn()
@ -69,7 +69,7 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
@ -120,7 +120,7 @@ describe('App Access Control Flow', () => {
access_token: 'token-1',
},
}
mockFetchAppDetailDirect.mockResolvedValue({
mockFetchAppDetail.mockResolvedValue({
...mockAppDetail,
access_mode: AccessMode.PUBLIC,
})
@ -128,7 +128,7 @@ describe('App Access Control Flow', () => {
it('refreshes app detail after confirming access control updates', async () => {
const { queryClient } = renderWithQueryClient(<AppPublisher publishedAt={1700000000} />)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue()
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' }))
fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific'))
@ -138,8 +138,14 @@ describe('App Access Control Flow', () => {
fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' }))
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
await waitFor(() => {
expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()

View File

@ -15,7 +15,8 @@ const mockAppState = vi.hoisted(() => ({
const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn())
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const mockFetchAppDetail = vi.hoisted(() => vi.fn())
const mockSetQueryData = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof mockAppState) => T): T => selector(mockAppState),
@ -26,6 +27,7 @@ vi.mock('@/service/use-workflow', () => ({
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args),
updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args),
updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args),
@ -33,7 +35,7 @@ vi.mock('@/service/apps', () => ({
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
setQueryData: mockSetQueryData,
}),
}))
@ -104,7 +106,14 @@ describe('CardView ACL edit guards', () => {
mockUpdateAppSiteStatus.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteConfig.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteAccessToken.mockResolvedValue({ code: 'token' })
mockInvalidateQueries.mockResolvedValue(undefined)
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
mode: 'chat',
permission_keys: ['app.acl.edit'],
site: {
title: 'Saved site title',
},
} as unknown as App)
})
// User-facing card actions should not mutate app settings without app ACL edit permission.
@ -122,6 +131,7 @@ describe('CardView ACL edit guards', () => {
expect(mockUpdateAppSiteStatus).not.toHaveBeenCalled()
expect(mockUpdateAppSiteConfig).not.toHaveBeenCalled()
expect(mockUpdateAppSiteAccessToken).not.toHaveBeenCalled()
expect(mockFetchAppDetail).not.toHaveBeenCalled()
})
it('should call write APIs when app ACL edit permission is present', async () => {
@ -153,7 +163,35 @@ describe('CardView ACL edit guards', () => {
expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({
url: '/apps/app-1/site/access-token-reset',
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalled()
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
})
it('should refresh the Zustand app detail after saving webapp settings', async () => {
const user = userEvent.setup()
mockAppState.appDetail.permission_keys = ['app.acl.edit']
render(<CardView appId="app-1" />)
await user.click(screen.getByRole('button', { name: /save webapp/ }))
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
})
})
})

View File

@ -20,6 +20,7 @@ import { webSocketClient } from '@/app/components/workflow/collaboration/core/we
import { isTriggerNode } from '@/app/components/workflow/types'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
@ -40,6 +41,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canEditApp = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
@ -88,12 +90,14 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const updateAppDetail = useCallback(async () => {
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appId] })
const res = await fetchAppDetail({ url: '/apps', id: appId })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appId], res)
setAppDetail({ ...res })
}
catch (error) {
console.error(error)
}
}, [appId, queryClient])
}, [appId, queryClient, setAppDetail])
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = err ? 'error' : 'success'

View File

@ -19,7 +19,7 @@ const mockRefetch = vi.fn()
const mockUseGetUserCanAccessApp = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockToastError = vi.fn()
const mockWindowOpen = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
@ -90,7 +90,7 @@ vi.mock('@/service/explore', () => ({
const mockPublishToCreatorsPlatform = vi.fn()
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args),
}))
@ -211,7 +211,7 @@ describe('AppPublisher', () => {
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetailDirect.mockResolvedValue({
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
@ -416,7 +416,7 @@ describe('AppPublisher', () => {
publishedAt={Date.now()}
/>,
)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue()
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
@ -426,8 +426,14 @@ describe('AppPublisher', () => {
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
})
it('should open the installed explore page through the async window helper', async () => {
@ -667,7 +673,7 @@ describe('AppPublisher', () => {
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
expect(mockFetchAppDetail).not.toHaveBeenCalled()
})
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
})

View File

@ -39,7 +39,7 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { publishToCreatorsPlatform } from '@/service/apps'
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
@ -130,6 +130,7 @@ export function AppPublisher({
const workflowStore = use(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const canManageTools = useCanManageTools()
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -242,7 +243,9 @@ export function AppPublisher({
if (!appDetail)
return
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appDetail.id] })
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res)
setAppDetail({ ...res })
}
finally {
setShowAppAccessControl(false)

View File

@ -17,6 +17,7 @@ const mockPush = vi.fn()
const mockSetAppDetail = vi.fn()
const mockOnChangeStatus = vi.fn()
const mockOnGenerateCode = vi.fn()
const mockFetchAppDetail = vi.fn()
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null
let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
@ -59,6 +60,10 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({
default: ({ appId }: { appId: string }) => <div data-testid="secret-key-button">{appId}</div>,
}))
@ -125,6 +130,14 @@ describe('AppCard', () => {
groups: [],
members: [],
}
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
site: {
app_base_url: 'https://example.com',
access_token: 'access-token',
},
} as AppDetailResponse)
})
it('should open the published webapp when launch is clicked', () => {
@ -405,7 +418,7 @@ describe('AppCard', () => {
onGenerateCode={mockOnGenerateCode}
/>,
)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue()
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByText('publishApp.notSet'))
expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
@ -413,8 +426,14 @@ describe('AppCard', () => {
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
})
it('should surface the learn-more tooltip action for workflows without a start node', () => {
@ -467,14 +486,14 @@ describe('AppCard', () => {
it('should report refresh failures from access control updates', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
mockFetchAppDetail.mockRejectedValueOnce(new Error('refresh failed'))
const { queryClient } = render(
render(
<AppCard
appInfo={appInfo}
onChangeStatus={mockOnChangeStatus}
/>,
)
vi.spyOn(queryClient, 'invalidateQueries').mockRejectedValueOnce(new Error('refresh failed'))
fireEvent.click(screen.getByText('publishApp.notSet'))
fireEvent.click(screen.getByText('confirm-access-control'))

View File

@ -19,6 +19,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { fetchAppDetail } from '@/service/apps'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
@ -83,6 +84,7 @@ function AppCard({
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
@ -157,13 +159,15 @@ function AppCard({
return
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appDetail.id] })
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res)
setAppDetail({ ...res })
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, queryClient])
}, [appDetail, queryClient, setAppDetail])
const operationKeys = useMemo(() => getAppCardOperationKeys({
cardType,

View File

@ -314,6 +314,12 @@ describe('ChatInputArea', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeInTheDocument()
})
it('should render a custom send button label when provided', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} sendButtonLabel="Start build" />)
expect(screen.getByRole('button', { name: 'Start build' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.send' })).not.toBeInTheDocument()
})
})
// -------------------------------------------------------------------------

View File

@ -41,6 +41,7 @@ type ChatInputAreaProps = {
theme?: Theme | null
isResponding?: boolean
disabled?: boolean
sendButtonLabel?: string
/**
* Controls whether pressing Enter sends the message.
* - true (default): Enter sends, Shift+Enter inserts newline
@ -49,7 +50,7 @@ type ChatInputAreaProps = {
*/
sendOnEnter?: boolean
}
const ChatInputArea = ({ readonly, botName, placeholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const ChatInputArea = ({ readonly, botName, placeholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendButtonLabel, sendOnEnter = true }: ChatInputAreaProps) => {
const { t } = useTranslation()
const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight()
const [query, setQuery] = useState('')
@ -141,7 +142,7 @@ const ChatInputArea = ({ readonly, botName, placeholder, showFeatureBar, showFil
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
})
}, [t])
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} sendButtonLabel={sendButtonLabel} theme={theme} />)
return (
<>
<div className={cn('relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md', isDragActive && 'border border-dashed border-components-option-card-option-selected-border', disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none')}>

View File

@ -22,6 +22,7 @@ type OperationProps = {
speechToTextConfig?: EnableType
onShowVoiceInput?: () => void
onSend: () => void
sendButtonLabel?: string
theme?: Theme | null
ref?: Ref<HTMLDivElement>
}
@ -32,6 +33,7 @@ const Operation: FC<OperationProps> = ({
speechToTextConfig,
onShowVoiceInput,
onSend,
sendButtonLabel,
theme,
}) => {
const { t } = useTranslation()
@ -62,8 +64,11 @@ const Operation: FC<OperationProps> = ({
}
</div>
<Button
aria-label={t('operation.send', { ns: 'common' })}
className="ml-3 w-8 px-0"
aria-label={sendButtonLabel ? undefined : t('operation.send', { ns: 'common' })}
className={cn(
'ml-3',
sendButtonLabel ? 'px-3' : 'w-8 px-0',
)}
variant="primary"
onClick={readonly ? noop : onSend}
style={
@ -74,7 +79,7 @@ const Operation: FC<OperationProps> = ({
: {}
}
>
<RiSendPlane2Fill className="size-4" aria-hidden="true" />
{sendButtonLabel || <RiSendPlane2Fill className="size-4" aria-hidden="true" />}
</Button>
</div>
</div>

View File

@ -72,6 +72,7 @@ export type ChatProps = {
inputDisabled?: boolean
inputPlaceholder?: string
inputPlaceholderBotName?: string
sendButtonLabel?: string
sidebarCollapseState?: boolean
hideAvatar?: boolean
sendOnEnter?: boolean
@ -120,6 +121,7 @@ const Chat: FC<ChatProps> = ({
inputDisabled,
inputPlaceholder,
inputPlaceholderBotName,
sendButtonLabel,
sidebarCollapseState,
hideAvatar,
sendOnEnter,
@ -262,6 +264,7 @@ const Chat: FC<ChatProps> = ({
theme={themeBuilder?.theme}
isResponding={isResponding}
readonly={readonly}
sendButtonLabel={sendButtonLabel}
sendOnEnter={sendOnEnter}
/>
)

View File

@ -36,6 +36,8 @@ type InfotipProps = {
'aria-label': string
/** Placement of the popup relative to the trigger. Defaults to `top`. */
'placement'?: Placement
/** Distance between the trigger and popup. Defaults to the popover primitive spacing. */
'sideOffset'?: number
/** Extra classes on the outer trigger wrapper (layout / margin). */
'className'?: string
/** Extra classes on the `?` icon itself (size / color overrides). */
@ -52,6 +54,7 @@ export function Infotip({
children,
'aria-label': ariaLabel,
placement = 'top',
sideOffset,
className,
iconClassName,
popupClassName,
@ -79,6 +82,7 @@ export function Infotip({
</PopoverTrigger>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
popupClassName={cn('max-w-[300px] rounded-md px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
>
{children}

View File

@ -13,8 +13,8 @@ describe('AccountSetting Constants', () => {
it('should have correct ACCOUNT_SETTING_TAB values', () => {
expect(ACCOUNT_SETTING_TAB.PROVIDER).toBe('provider')
expect(ACCOUNT_SETTING_TAB.MEMBERS).toBe('members')
expect(ACCOUNT_SETTING_TAB.PERMISSIONS).toBe('permissions')
expect(ACCOUNT_SETTING_TAB.ACCESS_RULES).toBe('access-rules')
expect(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS).toBe('roles-and-permissions')
expect(ACCOUNT_SETTING_TAB.PERMISSION_SET).toBe('permission-set')
expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('custom-endpoint')
@ -28,8 +28,8 @@ describe('AccountSetting Constants', () => {
})
it('isValidSettingsTab should include integrations tabs', () => {
expect(isValidSettingsTab('permissions')).toBe(true)
expect(isValidSettingsTab('access-rules')).toBe(true)
expect(isValidSettingsTab('roles-and-permissions')).toBe(true)
expect(isValidSettingsTab('permission-set')).toBe(true)
expect(isValidSettingsTab('billing')).toBe(true)
expect(isValidSettingsTab('preferences')).toBe(true)
expect(isValidSettingsTab('language')).toBe(true)

View File

@ -241,7 +241,7 @@ describe('AccountSetting', () => {
expect(screen.queryByText('common.settings.provider'))!.not.toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.resourceAccess' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.permissionSet' })).toBeInTheDocument()
expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument()
expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument()
expect(screen.queryByText('common.settings.customEndpoint'))!.not.toBeInTheDocument()
@ -357,7 +357,7 @@ describe('AccountSetting', () => {
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.resourceAccess' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.permissionSet' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.preferences' })).toBeInTheDocument()
@ -400,7 +400,7 @@ describe('AccountSetting', () => {
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
})
it('should hide role and resource access entries when role management permission is missing', () => {
it('should hide role and permission set entries when role management permission is missing', () => {
// Arrange
const contextWithoutRoleManagePermission = {
...baseAppContextValue,
@ -415,22 +415,22 @@ describe('AccountSetting', () => {
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.permissionSet' })).not.toBeInTheDocument()
})
it('should hide role and resource access entries when RBAC is disabled', () => {
it('should hide role and permission set entries when RBAC is disabled', () => {
// Act
renderAccountSetting({ rbacEnabled: false })
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.permissionSet' })).not.toBeInTheDocument()
})
it('should not render direct role pages when RBAC is disabled', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.ACCESS_RULES, rbacEnabled: false })
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PERMISSION_SET, rbacEnabled: false })
// Assert
expect(screen.queryByTestId('access-rules-page')).not.toBeInTheDocument()
@ -554,9 +554,9 @@ describe('AccountSetting', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' }))
expect(screen.getByTestId('permissions-page')).toBeInTheDocument()
// Resource Access
fireEvent.click(screen.getByRole('button', { name: 'common.settings.resourceAccess' }))
expect(screen.getByText('common.settings.resourceAccessDescription')).toBeInTheDocument()
// Permission Set
fireEvent.click(screen.getByRole('button', { name: 'common.settings.permissionSet' }))
expect(screen.getByText('common.settings.permissionSetDescription')).toBeInTheDocument()
expect(screen.getByTestId('access-rules-page')).toBeInTheDocument()
// Language

View File

@ -73,4 +73,46 @@ describe('WorkspaceRoleCheckboxList', () => {
expect(screen.getByRole('radio', { name: /First role/i })).toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: /First role/i })).not.toBeInTheDocument()
})
it('should show legacy role descriptions when only one role is allowed', () => {
vi.mocked(useWorkspaceRoleList).mockReturnValue({
data: {
pages: [{
data: [
createRole({ id: 'admin', name: 'admin' }),
createRole({ id: 'editor', name: 'editor' }),
createRole({ id: 'normal', name: 'normal' }),
createRole({ id: 'dataset_operator', name: 'dataset_operator' }),
],
pagination: {
total_count: 4,
per_page: 20,
current_page: 1,
total_pages: 1,
},
}],
pageParams: [1],
},
isLoading: false,
error: null,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
} as unknown as ReturnType<typeof useWorkspaceRoleList>)
render(
<WorkspaceRoleCheckboxList
selectedRoleIds={['editor']}
selectedRoles={[createRole({ id: 'editor', name: 'editor' })]}
allowMultipleRoles={false}
onSelectedRolesChange={vi.fn()}
/>,
)
expect(screen.getByText('common.members.adminTip')).toBeInTheDocument()
expect(screen.getByText('common.members.editorTip')).toBeInTheDocument()
expect(screen.getByText('common.members.normalTip')).toBeInTheDocument()
expect(screen.getByText('common.members.datasetOperatorTip')).toBeInTheDocument()
expect(screen.queryByText('permission.role.noDescription')).not.toBeInTheDocument()
})
})

View File

@ -6,8 +6,8 @@ export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
export const ACCOUNT_SETTING_TAB = {
PROVIDER: 'provider',
MEMBERS: 'members',
PERMISSIONS: 'permissions',
ACCESS_RULES: 'access-rules',
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
PERMISSION_SET: 'permission-set',
BILLING: 'billing',
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'custom-endpoint',
@ -22,8 +22,8 @@ export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS
const WORKSPACE_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.MEMBERS,
ACCOUNT_SETTING_TAB.PERMISSIONS,
ACCOUNT_SETTING_TAB.ACCESS_RULES,
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
ACCOUNT_SETTING_TAB.PERMISSION_SET,
ACCOUNT_SETTING_TAB.BILLING,
ACCOUNT_SETTING_TAB.CUSTOM,
] as const

View File

@ -63,7 +63,7 @@ export default function AccountSetting({
const activeMenu = (() => {
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.PREFERENCES
if ((normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSIONS || normalizedActiveTab === ACCOUNT_SETTING_TAB.ACCESS_RULES) && !canManageWorkspaceRoles)
if ((normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS || normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) && !canManageWorkspaceRoles)
return ACCOUNT_SETTING_TAB.MEMBERS
return normalizedActiveTab
})()
@ -83,15 +83,15 @@ export default function AccountSetting({
activeIcon: <span className={cn('i-ri-group-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.PERMISSIONS,
key: ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
name: t('settings.rolesAndPermissions', { ns: 'common' }),
icon: <span className={cn('i-ri-shield-user-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-shield-user-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.ACCESS_RULES,
name: t('settings.resourceAccess', { ns: 'common' }),
description: t('settings.resourceAccessDescription', { ns: 'common' }),
key: ACCOUNT_SETTING_TAB.PERMISSION_SET,
name: t('settings.permissionSet', { ns: 'common' }),
description: t('settings.permissionSetDescription', { ns: 'common' }),
icon: <span className={cn('i-ri-lock-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-lock-2-fill', iconClassName)} />,
},
@ -136,8 +136,8 @@ export default function AccountSetting({
visibleTabs.push(ACCOUNT_SETTING_TAB.MEMBERS)
if (canManageWorkspaceRoles) {
visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSIONS)
visibleTabs.push(ACCOUNT_SETTING_TAB.ACCESS_RULES)
visibleTabs.push(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS)
visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSION_SET)
}
if (canViewBilling)
@ -262,8 +262,8 @@ export default function AccountSetting({
/>
)}
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSIONS && <PermissionsPage containerRef={scrollContainerRef} />}
{activeMenu === ACCOUNT_SETTING_TAB.ACCESS_RULES && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && <PermissionsPage containerRef={scrollContainerRef} />}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}

View File

@ -224,6 +224,18 @@ describe('MembersPage', () => {
expect(screen.getByTestId('member-row-1').children[2])!.toHaveClass('min-w-0', 'grow')
})
it('should render plural roles column header when RBAC is enabled', () => {
renderWithSystemFeatures(<MembersPage />, {
systemFeatures: {
is_email_setup: true,
rbac_enabled: true,
},
})
expect(screen.getByText('common.members.roles', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('min-w-0', 'grow')
expect(screen.queryByText('common.members.role', { selector: '.system-xs-medium-uppercase' })).not.toBeInTheDocument()
})
it('should open and close invite modal', async () => {
const user = userEvent.setup()

View File

@ -2,7 +2,7 @@ import type { Role } from '@/models/access-control'
import type { Member } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
@ -97,6 +97,32 @@ describe('MemberMenu', () => {
} as unknown as ReturnType<typeof useWorkspaceRoleList>)
})
it('should show edit role copy when multiple roles are disabled', async () => {
const user = userEvent.setup()
renderWithSystemFeatures(
<MemberMenu
member={member}
isCurrentUser={false}
allowMultipleRoles={false}
/>,
{
systemFeatures: {
rbac_enabled: false,
},
},
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
expect(screen.getByRole('menuitem', { name: /common\.members\.editRole/i })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /common\.members\.assignRoles/i })).not.toBeInTheDocument()
await user.click(screen.getByRole('menuitem', { name: /common\.members\.editRole/i }))
expect(screen.getByRole('dialog', { name: /common\.members\.editRole/i })).toBeInTheDocument()
})
it('should submit only one selected role from the assign modal when RBAC is disabled', async () => {
const user = userEvent.setup()
@ -114,7 +140,7 @@ describe('MemberMenu', () => {
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.assignRoles/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.editRole/i }))
await user.click(screen.getByRole('radio', { name: /Second role/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
@ -124,7 +150,7 @@ describe('MemberMenu', () => {
}, expect.any(Object))
})
it('should refresh and invalidate members after removing a member', async () => {
it('should require confirmation before removing a member', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const membersQueryKey = [...commonQueryKeys.members, 'en-US']
@ -143,8 +169,18 @@ describe('MemberMenu', () => {
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i }))
expect(deleteMemberOrCancelInvitation).toHaveBeenCalledWith({
url: '/workspaces/current/members/member-1',
const dialog = screen.getByRole('alertdialog', {
name: /common\.members\.removeFromTeamConfirmTitle:\{"memberName":"Member User"\}/i,
})
expect(dialog).toHaveTextContent('common.members.removeFromTeamConfirmDescription')
expect(deleteMemberOrCancelInvitation).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i }))
await waitFor(() => {
expect(deleteMemberOrCancelInvitation).toHaveBeenCalledWith({
url: '/workspaces/current/members/member-1',
})
})
expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true)
expect(toast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')

View File

@ -19,4 +19,14 @@ describe('RoleBadges', () => {
expect(screen.queryByTitle('Editor')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should keep the wrapper rendered when role names are empty', () => {
const { container } = render(<RoleBadges roleNames={[]} className="role-badges-empty" />)
const wrapper = container.querySelector('.role-badges-empty')
expect(wrapper).toBeInTheDocument()
expect(wrapper).toBeEmptyDOMElement()
})
})
})

View File

@ -49,6 +49,33 @@ describe('AssignRolesModal', () => {
})
describe('Role selection', () => {
it('should hide selected count when multiple roles are disabled', () => {
render(
<AssignRolesModal
selectedRoles={[roles[0]!]}
allowMultipleRoles={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.queryByText(/common\.members\.assignRolesModal\.selectedCount/i)).not.toBeInTheDocument()
})
it('should show single-role description when multiple roles are disabled', () => {
render(
<AssignRolesModal
selectedRoles={[roles[0]!]}
allowMultipleRoles={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByText(/common\.members\.assignRolesModal\.singleDescription/i)).toBeInTheDocument()
expect(screen.queryByText(/common\.members\.assignRolesModal\.description/i)).not.toBeInTheDocument()
})
it('should disable confirm when the last selected role is unchecked', async () => {
const user = userEvent.setup()

View File

@ -32,6 +32,19 @@ const AssignRolesModalBody = ({
const [selected, setSelected] = useState(selectedRoles)
const selectedRoleIds = selected.map(role => role.id)
const isConfirmDisabled = selected.length === 0
const title = allowMultipleRoles
? t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })
: t('members.editRole', { ns: 'common', defaultValue: 'Edit Role' })
const description = allowMultipleRoles
? t('members.assignRolesModal.description', {
ns: 'common',
defaultValue:
'Select roles to assign to this member. All permissions from selected roles will be combined.',
})
: t('members.assignRolesModal.singleDescription', {
ns: 'common',
defaultValue: 'Select one role to assign to this member.',
})
const handleConfirm = () => {
if (isConfirmDisabled)
@ -50,14 +63,10 @@ const AssignRolesModalBody = ({
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })}
{title}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('members.assignRolesModal.description', {
ns: 'common',
defaultValue:
'Select roles to assign to this member. All permissions from selected roles will be combined.',
})}
{description}
</DialogDescription>
</div>
</div>
@ -69,14 +78,16 @@ const AssignRolesModalBody = ({
onSelectedRolesChange={setSelected}
/>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('members.assignRolesModal.selectedCount', {
ns: 'common',
count: selected.length,
})}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-3 border-t border-divider-subtle px-6 py-4">
{allowMultipleRoles && (
<div className="system-xs-regular text-text-tertiary">
{t('members.assignRolesModal.selectedCount', {
ns: 'common',
count: selected.length,
})}
</div>
)}
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>

View File

@ -45,6 +45,9 @@ const MembersPage = () => {
const [detailsMember, setDetailsMember] = useState<Member | null>(null)
const canManageMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage')
const roleColumnLabel = systemFeatures.rbac_enabled
? t('members.roles', { ns: 'common' })
: t('members.role', { ns: 'common' })
const handleOpenDetails = useCallback((member: Member) => {
setDetailsMember(member)
@ -151,7 +154,7 @@ const MembersPage = () => {
<div className="flex min-w-120 items-center border-b border-divider-regular py-1.75">
<div className="w-65 shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.name', { ns: 'common' })}</div>
<div className="w-30 shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
<div className="min-w-0 grow px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
<div className="min-w-0 grow px-3 system-xs-medium-uppercase text-text-tertiary">{roleColumnLabel}</div>
</div>
<div className="relative min-w-120">
{accounts.map(account => (

View File

@ -114,6 +114,39 @@ describe('RoleSelector', () => {
expect(getRoleOption('Editor')).toHaveAttribute('aria-checked', 'true')
})
it('should show legacy descriptions for built-in roles without descriptions', async () => {
const user = userEvent.setup()
mockUseWorkspaceRoleList({
pages: [{
data: [
createRole({ id: 'admin', name: 'admin', description: '' }),
createRole({ id: 'editor', name: 'editor', description: '' }),
createRole({ id: 'normal', name: 'normal', description: '' }),
createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }),
],
pagination: {
total_count: 4,
per_page: 20,
current_page: 1,
total_pages: 1,
},
}],
})
render(<RoleSelectorWrapper initialRole="" />)
await user.click(getTrigger())
const roleMenu = getRoleMenu()
expect(within(roleMenu).getByText(/common\.members\.adminTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.editorTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.normalTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.datasetOperatorTip/i)).toBeInTheDocument()
expect(within(roleMenu).queryByText(/permission\.role\.noDescription/i)).not.toBeInTheDocument()
})
it('should update selected role name after user chooses a role', async () => {
const user = userEvent.setup()

View File

@ -1,3 +1,4 @@
import type { Role } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -19,6 +20,28 @@ type RoleSelectorProps = {
}
const PAGE_SIZE = 20
const LEGACY_ROLE_DESCRIPTION_KEY_MAP = {
admin: 'members.adminTip',
editor: 'members.editorTip',
normal: 'members.normalTip',
dataset_operator: 'members.datasetOperatorTip',
} as const
type LegacyRoleKey = keyof typeof LEGACY_ROLE_DESCRIPTION_KEY_MAP
const normalizeLegacyRoleKey = (value: string) => value.trim().toLowerCase()
const isLegacyRoleKey = (value: string): value is LegacyRoleKey =>
Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value)
const getLegacyRoleDescriptionKey = (role: Role) => {
const candidateKeys = [
normalizeLegacyRoleKey(role.name),
normalizeLegacyRoleKey(role.id),
]
return candidateKeys.find(isLegacyRoleKey)
}
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
const { t } = useTranslation()
@ -88,6 +111,26 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
setOpen(false)
}
const getRoleDescription = (role: Role) => {
if (role.description)
return role.description
const legacyRoleDescriptionKey = getLegacyRoleDescriptionKey(role)
switch (legacyRoleDescriptionKey) {
case 'admin':
return t('members.adminTip', { ns: 'common' })
case 'editor':
return t('members.editorTip', { ns: 'common' })
case 'normal':
return t('members.normalTip', { ns: 'common' })
case 'dataset_operator':
return t('members.datasetOperatorTip', { ns: 'common' })
}
return t('role.noDescription', { ns: 'permission' })
}
return (
<DropdownMenu
open={open}
@ -141,7 +184,7 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
>
<div className="relative min-w-0 pl-5">
<div className="truncate text-sm leading-5 text-text-secondary">{role.name}</div>
<div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">{role.description || t('role.noDescription', { ns: 'permission' })}</div>
<div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">{getRoleDescription(role)}</div>
{value === role.id && (
<div
aria-hidden="true"

View File

@ -76,6 +76,39 @@ describe('MemberDetailsModal', () => {
})
describe('Rendering', () => {
it('should render edit role action when multiple roles are disabled', () => {
render(
<MemberDetailsModal
member={member}
canAssignRoles
allowMultipleRoles={false}
onClose={vi.fn()}
onAssignSubmit={vi.fn()}
/>,
)
const editButton = screen.getByRole('button', { name: /common\.operation\.edit/i })
expect(editButton).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /members\.memberDetails\.assign/i })).not.toBeInTheDocument()
expect(editButton.querySelector('.i-ri-edit-line')).toBeInTheDocument()
expect(editButton.querySelector('.i-ri-add-line')).not.toBeInTheDocument()
})
it('should render singular assigned role label when there is one role', () => {
render(
<MemberDetailsModal
member={member}
canAssignRoles
onClose={vi.fn()}
onAssignSubmit={vi.fn()}
/>,
)
expect(screen.getByText(/common\.members\.memberDetails\.assignedRole:/i)).toBeInTheDocument()
expect(screen.queryByText(/common\.members\.memberDetails\.assignedRoles/i)).not.toBeInTheDocument()
})
it('should render role loading state without assigned role chips or count', () => {
vi.mocked(useRolesOfMember).mockReturnValue({
data: undefined,
@ -99,6 +132,26 @@ describe('MemberDetailsModal', () => {
})
describe('Role actions', () => {
it('should keep role chips readonly when multiple roles are disabled', async () => {
const user = userEvent.setup()
render(
<MemberDetailsModal
member={member}
canAssignRoles
allowMultipleRoles={false}
onClose={vi.fn()}
onAssignSubmit={vi.fn()}
/>,
)
expect(screen.queryByRole('button', { name: /Custom role/i })).not.toBeInTheDocument()
await user.click(screen.getByText('Custom role'))
expect(screen.queryByRole('menuitem', { name: /common\.operation\.remove/i })).not.toBeInTheDocument()
})
it('should not show role removal controls when role assignment is not allowed', () => {
render(
<MemberDetailsModal
@ -190,7 +243,7 @@ describe('MemberDetailsModal', () => {
/>,
)
await user.click(screen.getByRole('button', { name: /members\.memberDetails\.assign/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.edit/i }))
await user.click(screen.getByRole('radio', { name: /Second role/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))

View File

@ -45,6 +45,25 @@ const MemberDetailsModal = ({
const roles = useMemo(() => rolesOfMember?.roles ?? [], [rolesOfMember?.roles])
const selectedRoles = pendingRoles ?? roles
const selectedRoleIds = useMemo(() => selectedRoles.map(role => role.id), [selectedRoles])
const canRemoveRoles = canAssignRoles && allowMultipleRoles
const assignedRolesLabel = selectedRoleIds.length === 1
? t('members.memberDetails.assignedRole', {
ns: 'common',
defaultValue: 'Assigned Role',
})
: t('members.memberDetails.assignedRoles', {
ns: 'common',
defaultValue: 'Assigned Roles',
})
const assignActionIconClassName = allowMultipleRoles
? 'mr-0.5 i-ri-add-line h-3.5 w-3.5'
: 'mr-0.5 i-ri-edit-line h-3.5 w-3.5'
const assignActionLabel = allowMultipleRoles
? t('members.memberDetails.assign', {
ns: 'common',
defaultValue: 'Assign',
})
: t('operation.edit', { ns: 'common' })
const builtinRoles = useMemo(() => selectedRoles.filter(role => role.is_builtin), [selectedRoles])
const customRoles = useMemo(() => selectedRoles.filter(role => !role.is_builtin), [selectedRoles])
@ -110,10 +129,7 @@ const MemberDetailsModal = ({
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 system-sm-semibold text-text-secondary">
<span>
{t('members.memberDetails.assignedRoles', {
ns: 'common',
defaultValue: 'Assigned Roles',
})}
{assignedRolesLabel}
</span>
{!isLoadingRolesOfMember && (
<span className="system-xs-medium text-text-tertiary">
@ -129,12 +145,9 @@ const MemberDetailsModal = ({
>
<span
aria-hidden
className="mr-0.5 i-ri-add-line h-3.5 w-3.5"
className={assignActionIconClassName}
/>
{t('members.memberDetails.assign', {
ns: 'common',
defaultValue: 'Assign',
})}
{assignActionLabel}
</Button>
)}
</div>
@ -162,7 +175,7 @@ const MemberDetailsModal = ({
label={role.name}
isOwner={role.role_tag === 'owner'}
permissionKeys={role.permission_keys}
onRemove={canAssignRoles ? handleRemove : undefined}
onRemove={canRemoveRoles ? handleRemove : undefined}
/>
))}
</div>
@ -183,7 +196,7 @@ const MemberDetailsModal = ({
label={role.name}
isOwner={role.role_tag === 'owner'}
permissionKeys={role.permission_keys}
onRemove={canAssignRoles ? handleRemove : undefined}
onRemove={canRemoveRoles ? handleRemove : undefined}
/>
))}
</div>

View File

@ -1,6 +1,15 @@
'use client'
import type { Role } from '@/models/access-control'
import type { Member } from '@/models/common'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -38,6 +47,8 @@ const MemberMenu = ({
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false)
const [removing, setRemoving] = useState(false)
const isOwner = member.role === 'owner'
const canAssignRoles = !isOwner && !isCurrentUser
@ -45,6 +56,10 @@ const MemberMenu = ({
const showTransferOwnership = isOwner && canTransferOwnership
const selectedRoles = member.roles || []
const memberName = member.name || member.email
const assignRolesLabel = allowMultipleRoles
? t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })
: t('members.editRole', { ns: 'common', defaultValue: 'Edit Role' })
const handleOpenAssignRoles = useCallback(() => {
setOpen(false)
@ -68,15 +83,24 @@ const MemberMenu = ({
})
}, [allowMultipleRoles, member.id, t, updateRolesOfMember])
const handleRemove = useCallback(async () => {
const handleOpenRemoveConfirm = useCallback(() => {
setOpen(false)
setRemoveConfirmOpen(true)
}, [])
const handleRemove = useCallback(async () => {
setRemoving(true)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setRemoveConfirmOpen(false)
}
catch {
}
finally {
setRemoving(false)
}
}, [member.id, queryClient, t])
const handleTransferOwnership = useCallback(() => {
@ -120,7 +144,7 @@ const MemberMenu = ({
className="system-sm-medium text-text-secondary"
onClick={handleOpenAssignRoles}
>
{t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })}
{assignRolesLabel}
</DropdownMenuItem>
)}
{showTransferOwnership && (
@ -138,13 +162,34 @@ const MemberMenu = ({
<DropdownMenuItem
variant="destructive"
className="system-sm-medium"
onClick={handleRemove}
onClick={handleOpenRemoveConfirm}
>
{t('members.removeFromTeam', { ns: 'common' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={removeConfirmOpen} onOpenChange={open => !open && setRemoveConfirmOpen(false)}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('members.removeFromTeamConfirmTitle', { ns: 'common', memberName })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('members.removeFromTeamConfirmDescription', { ns: 'common' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={removing}
onClick={handleRemove}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{assignModalOpen && (
<AssignRolesModal
selectedRoles={selectedRoles}

View File

@ -29,9 +29,6 @@ type RoleBadgesProps = {
}
const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => {
if (!roleNames.length)
return null
const visible = roleNames.slice(0, max)
const overflow = roleNames.slice(max)

View File

@ -24,6 +24,14 @@ type WorkspaceRoleCheckboxListProps = {
const PAGE_SIZE = 20
const EMPTY_DISABLED_ROLE_IDS: string[] = []
const LEGACY_ROLE_DESCRIPTION_KEY_MAP = {
admin: 'members.adminTip',
editor: 'members.editorTip',
normal: 'members.normalTip',
dataset_operator: 'members.datasetOperatorTip',
} as const
type LegacyRoleKey = keyof typeof LEGACY_ROLE_DESCRIPTION_KEY_MAP
const createSelectedRolePlaceholder = (id: string): Role => ({
id,
@ -37,6 +45,20 @@ const createSelectedRolePlaceholder = (id: string): Role => ({
role_tag: '',
})
const normalizeLegacyRoleKey = (value: string) => value.trim().toLowerCase()
const isLegacyRoleKey = (value: string): value is LegacyRoleKey =>
Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value)
const getLegacyRoleDescriptionKey = (role: Role) => {
const candidateKeys = [
normalizeLegacyRoleKey(role.name),
normalizeLegacyRoleKey(role.id),
]
return candidateKeys.find(isLegacyRoleKey)
}
const WorkspaceRoleCheckboxList = ({
selectedRoleIds,
selectedRoles,
@ -138,16 +160,34 @@ const WorkspaceRoleCheckboxList = ({
onSelectedRolesChange([role])
}, [disabledRoleIdSet, onSelectedRolesChange, roleById])
const renderRoleText = (role: Role) => (
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
const getRoleDescription = (role: Role) => {
if (role.description)
return role.description
const legacyRoleDescriptionKey = allowMultipleRoles
? undefined
: getLegacyRoleDescriptionKey(role)
if (legacyRoleDescriptionKey)
return t(LEGACY_ROLE_DESCRIPTION_KEY_MAP[legacyRoleDescriptionKey], { ns: 'common' })
return t('role.noDescription', { ns: 'permission' })
}
const renderRoleText = (role: Role) => {
const description = getRoleDescription(role)
return (
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{description}
</div>
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.description || t('role.noDescription', { ns: 'permission' })}
</div>
</div>
)
)
}
return (
<>

View File

@ -417,22 +417,20 @@ describe('IntegrationsPage', () => {
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
})
it('does not render the MCP management route without mcp.manage', () => {
it('renders the MCP route as read-only without mcp.manage', () => {
mockAppContextState.workspacePermissionKeys = ['tool.manage']
const { container } = renderIntegrationsPage(undefined, 'mcp')
renderIntegrationsPage(undefined, 'mcp')
expect(screen.queryByTestId('tool-provider-list')).not.toBeInTheDocument()
expect(container.firstElementChild).toBeNull()
expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp')
})
it.each(['custom-tool', 'workflow-tool'] as const)('does not render the %s management route without tool.manage', (section) => {
it.each(['custom-tool', 'workflow-tool'] as const)('renders the %s route as read-only without tool.manage', (section) => {
mockAppContextState.workspacePermissionKeys = ['mcp.manage']
const { container } = renderIntegrationsPage(undefined, section)
renderIntegrationsPage(undefined, section)
expect(screen.queryByTestId('tool-provider-list')).not.toBeInTheDocument()
expect(container.firstElementChild).toBeNull()
expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument()
})
it('remounts the tools section content when the route section changes', () => {
@ -529,7 +527,7 @@ describe('IntegrationsPage', () => {
expect(onSectionChange).toHaveBeenCalledTimes(2)
})
it('hides custom and workflow tool entries without tool.manage while keeping MCP with mcp.manage', () => {
it('keeps custom, workflow, and MCP tool entries visible without manage permissions', () => {
mockAppContextState.workspacePermissionKeys = ['mcp.manage']
renderIntegrationsPage(undefined, { section: 'provider', onSectionChange: vi.fn() })
@ -537,8 +535,8 @@ describe('IntegrationsPage', () => {
expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'workflow.common.workflowAsTool' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.swaggerAPIAsTool' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.common.workflowAsTool' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.swaggerAPIAsTool' })).toBeInTheDocument()
})
it('opens tools to the tools plugin page when the parent tools nav is clicked', () => {

View File

@ -365,18 +365,18 @@ describe('ProviderList', () => {
expect(screen.getByText('MCP')).toBeInTheDocument()
})
it('hides custom and workflow tabs without tool.manage while keeping MCP with mcp.manage', () => {
it('keeps custom and workflow tabs visible without tool.manage', () => {
mockAppContextState.workspacePermissionKeys = ['mcp.manage']
renderProviderList()
expect(screen.getByText('tools.type.builtIn')).toBeInTheDocument()
expect(screen.queryByText('tools.type.custom')).not.toBeInTheDocument()
expect(screen.queryByText('tools.type.workflow')).not.toBeInTheDocument()
expect(screen.getByText('tools.type.custom')).toBeInTheDocument()
expect(screen.getByText('tools.type.workflow')).toBeInTheDocument()
expect(screen.getByText('MCP')).toBeInTheDocument()
})
it('hides MCP tab without mcp.manage', () => {
it('keeps MCP tab visible without mcp.manage', () => {
mockAppContextState.workspacePermissionKeys = ['tool.manage']
renderProviderList()
@ -384,20 +384,19 @@ describe('ProviderList', () => {
expect(screen.getByText('tools.type.builtIn')).toBeInTheDocument()
expect(screen.getByText('tools.type.custom')).toBeInTheDocument()
expect(screen.getByText('tools.type.workflow')).toBeInTheDocument()
expect(screen.queryByText('MCP')).not.toBeInTheDocument()
expect(screen.getByText('MCP')).toBeInTheDocument()
})
it.each([
['api'],
['workflow'],
] as const)('does not query providers for %s category without tool.manage', (category) => {
['api', 'card-my-api'],
['workflow', 'card-wf-tool'],
] as const)('renders %s category read-only without tool.manage', (category, cardTestId) => {
mockAppContextState.workspacePermissionKeys = []
renderProviderList({ category })
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument()
expect(screen.queryByTestId('card-wf-tool')).not.toBeInTheDocument()
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
expect(screen.getByTestId(cardTestId)).toBeInTheDocument()
expect(screen.queryByTestId('custom-create-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('toolbar-add-custom-tool')).not.toBeInTheDocument()
})
@ -800,13 +799,14 @@ describe('ProviderList', () => {
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
})
it('does not render or query MCP management list without mcp.manage', () => {
it('renders MCP list read-only without mcp.manage', () => {
mockAppContextState.workspacePermissionKeys = ['tool.manage']
renderProviderList({ category: 'mcp' })
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
expect(screen.queryByTestId('mcp-list')).not.toBeInTheDocument()
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
expect(screen.getByTestId('mcp-list')).toHaveAttribute('data-show-create-card', 'false')
expect(screen.queryByTestId('toolbar-add-mcp')).not.toBeInTheDocument()
})

View File

@ -9,11 +9,6 @@ export type IntegrationHeader = {
title: string
}
type IntegrationNavOptions = {
canManageTools?: boolean
canManageMCP?: boolean
}
export const getPluginCategoryBySection = (section: IntegrationSection) => {
if (section === 'builtin')
return PluginCategoryEnum.tool
@ -25,12 +20,8 @@ export const getPluginCategoryBySection = (section: IntegrationSection) => {
return PluginCategoryEnum.extension
}
export function useIntegrationNav(section: IntegrationSection, options: IntegrationNavOptions = {}) {
export function useIntegrationNav(section: IntegrationSection) {
const { t } = useTranslation()
const {
canManageMCP = true,
canManageTools = true,
} = options
const providerItem = useMemo<IntegrationSidebarNavItemData>(() => ({
section: 'provider',
label: t('settings.provider', { ns: 'common' }),
@ -59,37 +50,32 @@ export function useIntegrationNav(section: IntegrationSection, options: Integrat
},
]
if (canManageMCP) {
items.push({
items.push(
{
section: 'mcp',
label: 'MCP',
icon: 'i-custom-vender-integrations-mcp',
iconClassName: 'h-[14.5px] w-[13.5px]',
className: 'pl-8',
})
}
if (canManageTools) {
items.push(
{
section: 'workflow-tool',
label: t('common.workflowAsTool', { ns: 'workflow' }),
icon: 'i-custom-vender-integrations-workflow-as-tool',
iconClassName: 'size-4',
className: 'pl-8',
},
{
section: 'custom-tool',
label: t('settings.swaggerAPIAsTool', { ns: 'common' }),
icon: 'i-custom-vender-integrations-custom-tool',
iconClassName: 'h-[14.5px] w-[12.5px]',
className: 'pl-8',
},
)
}
},
{
section: 'workflow-tool',
label: t('common.workflowAsTool', { ns: 'workflow' }),
icon: 'i-custom-vender-integrations-workflow-as-tool',
iconClassName: 'size-4',
className: 'pl-8',
},
{
section: 'custom-tool',
label: t('settings.swaggerAPIAsTool', { ns: 'common' }),
icon: 'i-custom-vender-integrations-custom-tool',
iconClassName: 'h-[14.5px] w-[12.5px]',
className: 'pl-8',
},
)
return items
}, [canManageMCP, canManageTools, t])
}, [t])
const secondaryItems = useMemo<IntegrationSidebarNavItemData[]>(() => [
{
section: 'trigger',

View File

@ -13,7 +13,6 @@ import {
buildMarketplaceUrlPathByIntegrationSection,
toolCategoryBySection,
} from '@/app/components/integrations/routes'
import { useCanManageMCP, useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
@ -105,8 +104,6 @@ export default function IntegrationsPage({
const docLink = useDocLink()
const router = useRouter()
const section = useIntegrationSection(routeSection)
const canManageMCP = useCanManageMCP()
const canManageTools = useCanManageTools()
const {
canDebugger,
canInstallPlugin,
@ -130,7 +127,7 @@ export default function IntegrationsPage({
providerItem,
secondaryItems,
toolItems,
} = useIntegrationNav(section, { canManageMCP, canManageTools })
} = useIntegrationNav(section)
const isToolSection = Boolean(toolCategoryBySection[section])
const [isToolsExpanded, setIsToolsExpanded] = useState(isToolSection)
const useFillLayout = section === 'provider' || section === 'data-source' || section === 'custom-endpoint' || isToolSection || isPluginCategory
@ -199,11 +196,6 @@ export default function IntegrationsPage({
</>
)
if (section === 'mcp' && !canManageMCP)
return null
if ((section === 'custom-tool' || section === 'workflow-tool') && !canManageTools)
return null
return (
<div className="flex h-full min-h-0 w-full flex-1 bg-components-panel-bg" style={sidebarWidthStyle}>
<aside className={cn(

View File

@ -104,22 +104,11 @@ const ProviderList = ({
const toolListFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName)
const showToolsUpdateSetting = activeTab === 'builtin' && canSetPluginPreferences
const showLabelFilter = activeTab === 'builtin'
const isToolManageTab = activeTab === 'api' || activeTab === 'workflow'
const isMCPManageTab = activeTab === 'mcp'
const canReadActiveTab = isMCPManageTab ? canManageMCP : !isToolManageTab || canManageTools
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
...(canManageTools
? [
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
{ value: 'workflow', text: t('type.workflow', { ns: 'tools' }) },
]
: []),
...(canManageMCP
? [
{ value: 'mcp', text: 'MCP' },
]
: []),
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
{ value: 'workflow', text: t('type.workflow', { ns: 'tools' }) },
{ value: 'mcp', text: 'MCP' },
]
const [tagFilterValue, setTagFilterValue] = useState<string[]>([])
const handleTagsChange = (value: string[]) => {
@ -136,13 +125,10 @@ const ProviderList = ({
const handleCreatedMCPProviderHandled = useCallback(() => {
setCreatedMCPProviderId(undefined)
}, [])
const { data: collectionList = [], isLoading: isCollectionListLoading, refetch } = useAllToolProviders(canReadActiveTab)
const { data: collectionList = [], isLoading: isCollectionListLoading, refetch } = useAllToolProviders()
const activeTabCollectionList = useMemo(() => {
if (!canReadActiveTab)
return []
return collectionList.filter(collection => collection.type === activeTab)
}, [activeTab, canReadActiveTab, collectionList])
}, [activeTab, collectionList])
const hasCategoryCollections = activeTabCollectionList.length > 0
const shouldShowCustomToolCreateCard = canManageTools && !(activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections)
const shouldShowMCPCreateCard = canManageMCP && !(activeTab === 'mcp' && hasCategoryCollections)
@ -249,7 +235,7 @@ const ProviderList = ({
tagFilterValue={tagFilterValue}
/>
)}
{activeTab === 'mcp' && canManageMCP && (
{activeTab === 'mcp' && (
<MCPList
searchText={keywords}
contentInset={contentInset}

View File

@ -7,6 +7,7 @@ const {
mockDownloadBlob,
mockExportMutateAsync,
mockOnRefresh,
mockRenderTagSelector,
mockIsCurrentWorkspaceEditor,
mockWorkspacePermissionKeys,
mockToastError,
@ -19,6 +20,7 @@ const {
mockIsCurrentWorkspaceEditor: vi.fn(() => true),
mockWorkspacePermissionKeys: vi.fn(() => ['snippets.create_and_modify', 'snippets.management']),
mockOnRefresh: vi.fn(),
mockRenderTagSelector: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
mockUpdateMutate: vi.fn(),
@ -79,21 +81,23 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: ({
onOpenTagManagement,
onTagsChange,
value,
}: {
TagSelector: (props: {
onOpenTagManagement: () => void
onTagsChange: () => void
value: Array<{ name: string }>
}) => (
<div data-testid="snippet-tags">
<span>{value.map(tag => tag.name).join(', ')}</span>
<button type="button" onClick={onOpenTagManagement}>manage tags</button>
<button type="button" onClick={onTagsChange}>sync tags</button>
</div>
),
canBindOrUnbindTags?: boolean
}) => {
mockRenderTagSelector(props)
const { onOpenTagManagement, onTagsChange, value } = props
return (
<div data-testid="snippet-tags">
<span>{value.map(tag => tag.name).join(', ')}</span>
<button type="button" onClick={onOpenTagManagement}>manage tags</button>
<button type="button" onClick={onTagsChange}>sync tags</button>
</div>
)
},
}))
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
@ -194,6 +198,30 @@ describe('SnippetCard', () => {
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
})
it('should pass snippet management permission to tag binding capability', () => {
mockWorkspacePermissionKeys.mockReturnValue(['snippets.management'])
render(<SnippetCard snippet={createSnippet()} />)
expect(mockRenderTagSelector).toHaveBeenCalledWith(expect.objectContaining({
type: 'snippet',
targetId: 'snippet-1',
canBindOrUnbindTags: true,
}))
})
it('should disable tag binding capability without snippet management permission', () => {
mockWorkspacePermissionKeys.mockReturnValue(['snippets.create_and_modify'])
render(<SnippetCard snippet={createSnippet()} />)
expect(mockRenderTagSelector).toHaveBeenCalledWith(expect.objectContaining({
type: 'snippet',
targetId: 'snippet-1',
canBindOrUnbindTags: false,
}))
})
it('should forward tag selector actions without navigating the card link', () => {
const onOpenTagManagement = vi.fn()
const onTagsChange = vi.fn()

View File

@ -170,6 +170,7 @@ const SnippetCard = ({
value={snippet.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
canBindOrUnbindTags={canManageSnippet}
/>
</div>
</div>

View File

@ -110,13 +110,16 @@ describe('MCPList', () => {
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should not render or query providers when user lacks mcp.manage', () => {
it('should render providers read-only when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = []
mockProviders = [
{ id: '1', name: 'Provider 1', type: 'mcp' },
]
const { container } = render(<MCPList searchText="" />)
render(<MCPList searchText="" />)
expect(container.firstElementChild).toBeNull()
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
expect(screen.queryByTestId('create-card')).not.toBeInTheDocument()
})

View File

@ -231,13 +231,13 @@ describe('MCPDetailContent', () => {
expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
})
it('should not render detail when user lacks mcp.manage', () => {
it('should render read-only detail when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = []
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByTestId('operation-dropdown')).not.toBeInTheDocument()
expect(screen.queryByText('Test MCP Server')).not.toBeInTheDocument()
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
})
@ -465,7 +465,7 @@ describe('MCPDetailContent', () => {
})
})
it('should not render authorize action when user lacks mcp.manage', () => {
it('should disable authorize action when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = []
const detail = createMockDetail({ is_team_authorization: false })
render(
@ -473,7 +473,7 @@ describe('MCPDetailContent', () => {
{ wrapper: createWrapper() },
)
expect(screen.queryByText('tools.mcp.authorize')).not.toBeInTheDocument()
expect(screen.getByText('tools.mcp.authorize').closest('button')).toBeDisabled()
})
})
@ -755,7 +755,7 @@ describe('MCPDetailContent', () => {
})
})
it('should not render OAuth authorization action when user lacks mcp.manage', async () => {
it('should not run OAuth authorization when user lacks mcp.manage', async () => {
mockWorkspacePermissionKeys = []
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
@ -765,7 +765,9 @@ describe('MCPDetailContent', () => {
{ wrapper: createWrapper() },
)
expect(screen.queryByText('tools.mcp.authorize')).not.toBeInTheDocument()
const authorizeButton = screen.getByText('tools.mcp.authorize').closest('button')!
expect(authorizeButton).toBeDisabled()
fireEvent.click(authorizeButton)
expect(mockAuthorizeMcp).not.toHaveBeenCalled()
})
})
@ -797,7 +799,7 @@ describe('MCPDetailContent', () => {
})
})
it('should not render authorized button when user lacks mcp.manage', () => {
it('should disable authorized button when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = []
const detail = createMockDetail({ is_team_authorization: true })
render(
@ -805,7 +807,7 @@ describe('MCPDetailContent', () => {
{ wrapper: createWrapper() },
)
expect(screen.queryByText('tools.auth.authorized')).not.toBeInTheDocument()
expect(screen.getByText('tools.auth.authorized').closest('button')).toBeDisabled()
})
})

View File

@ -60,7 +60,7 @@ const MCPDetailContent: FC<Props> = ({
const { t } = useTranslation()
const canManageMCP = useCanManageMCP()
const { data, isFetching: isGettingTools } = useMCPTools(canManageMCP && detail.is_team_authorization ? detail.id : '')
const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '')
const invalidateMCPTools = useInvalidateMCPTools()
const invalidateAllMCPTools = useInvalidateAllMCPTools()
const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools()
@ -162,7 +162,7 @@ const MCPDetailContent: FC<Props> = ({
handleAuthorize()
}, [])
if (!detail || !canManageMCP)
if (!detail)
return null
const identifierLabel = t('mcp.identifier', { ns: 'tools' })
const serverUrlLabel = t('mcp.modal.serverUrl', { ns: 'tools' })
@ -280,6 +280,7 @@ const MCPDetailContent: FC<Props> = ({
<Button
variant="primary"
onClick={handleUpdateTools}
disabled={!canManageMCP}
>
{t('mcp.getTools', { ns: 'tools' })}
</Button>
@ -293,7 +294,7 @@ const MCPDetailContent: FC<Props> = ({
{toolList.length === 1 && <div className="system-sm-semibold-uppercase text-text-secondary">{t('mcp.onlyTool', { ns: 'tools' })}</div>}
</div>
<div>
<Button size="small" onClick={showUpdateConfirm}>
<Button size="small" onClick={showUpdateConfirm} disabled={!canManageMCP}>
<span aria-hidden className="mr-1 i-ri-loop-left-line size-3.5" />
{t('mcp.update', { ns: 'tools' })}
</Button>
@ -344,7 +345,7 @@ const MCPDetailContent: FC<Props> = ({
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={isShowUpdateConfirm} onOpenChange={open => !open && hideUpdateConfirm()}>
<AlertDialog open={canManageMCP && isShowUpdateConfirm} onOpenChange={open => !open && hideUpdateConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">

View File

@ -29,7 +29,7 @@ const MCPList = ({
showCreateCard = true,
}: Props) => {
const canManageMCP = useCanManageMCP()
const { data: list = [] as ToolWithProvider[], isLoading, refetch } = useAllToolProviders(canManageMCP)
const { data: list = [] as ToolWithProvider[], isLoading, refetch } = useAllToolProviders()
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
const filteredList = useMemo(() => {
@ -95,9 +95,6 @@ const MCPList = ({
}
const contentPaddingClassName = toolsContentInsetClassNames[contentInset]
const contentFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName)
if (!canManageMCP)
return null
return (
<>
<div
@ -107,7 +104,7 @@ const MCPList = ({
isLoading && 'h-[calc(100vh-136px)] overflow-hidden',
)}
>
{!isLoading && showCreateCard && <NewMCPCard handleCreate={handleCreate} />}
{!isLoading && canManageMCP && showCreateCard && <NewMCPCard handleCreate={handleCreate} />}
{isLoading
? <ToolCardSkeletonGrid variant="mcp" />
: filteredList.map(provider => (

View File

@ -338,8 +338,11 @@ describe('ProviderDetail', () => {
expect(configureButton.querySelector('.i-ri-equalizer-2-line')).toBeInTheDocument()
})
it('does not fetch or enable custom tool management without tool.manage', async () => {
it('renders custom tool details read-only without tool.manage', async () => {
mockAppContextState.workspacePermissionKeys = []
mockFetchCustomToolList.mockResolvedValue([
{ name: 'custom-tool', label: { en_US: 'Custom Tool' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
])
render(
<ProviderDetail
@ -352,7 +355,8 @@ describe('ProviderDetail', () => {
const configureButton = (await screen.findByText('tools.createTool.editAction')).closest('button')!
expect(mockFetchCustomCollection).not.toHaveBeenCalled()
expect(mockFetchCustomToolList).not.toHaveBeenCalled()
expect(mockFetchCustomToolList).toHaveBeenCalledWith('test-collection')
expect(screen.getByTestId('tool-custom-tool')).toBeInTheDocument()
expect(configureButton).toBeDisabled()
})
})
@ -421,7 +425,7 @@ describe('ProviderDetail', () => {
expect(actions).toHaveClass('-mx-4', 'px-4', 'border-b-[0.5px]', 'border-divider-subtle')
})
it('does not fetch or show workflow tool edit actions without tool.manage', () => {
it('renders workflow tool details read-only without tool.manage', async () => {
mockAppContextState.workspacePermissionKeys = []
render(
@ -432,9 +436,16 @@ describe('ProviderDetail', () => {
/>,
)
expect(mockFetchWorkflowToolDetail).not.toHaveBeenCalled()
expect(screen.queryByText('tools.openInStudio')).not.toBeInTheDocument()
expect(screen.queryByText('tools.createTool.editAction')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-id')
})
const configureButton = (await screen.findByText('tools.createTool.editAction')).closest('button')!
expect(screen.getByText('tools.openInStudio')).toBeInTheDocument()
expect(configureButton).toBeDisabled()
fireEvent.click(configureButton)
expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument()
})
})

View File

@ -164,9 +164,6 @@ const ProviderDetail = ({
// workflow provider
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const getWorkflowToolProvider = useCallback(async () => {
if (!canManageTools)
return
setIsDetailLoading(true)
const res = await fetchWorkflowToolDetail(collection.id)
const payload = {
@ -184,7 +181,7 @@ const ProviderDetail = ({
}
setCustomCollection(payload)
setIsDetailLoading(false)
}, [canManageTools, collection.id])
}, [collection.id])
const removeWorkflowToolProvider = async () => {
if (!canManageTools)
return
@ -243,24 +240,18 @@ const ProviderDetail = ({
setToolList([])
}
else {
if (!canManageTools) {
setToolList([])
setIsDetailLoading(false)
return
}
const list = await fetchCustomToolList(collection.name)
setToolList(list)
}
}
catch { }
setIsDetailLoading(false)
}, [canManageTools, collection.name, collection.type])
}, [collection.name, collection.type])
useEffect(() => {
if (collection.type === CollectionType.custom && canManageTools)
getCustomProvider()
if (collection.type === CollectionType.workflow && canManageTools)
if (collection.type === CollectionType.workflow)
getWorkflowToolProvider()
getProviderToolList()
}, [canManageTools, collection.name, collection.type, getCustomProvider, getProviderToolList, getWorkflowToolProvider])
@ -331,7 +322,7 @@ const ProviderDetail = ({
nativeButton={false}
variant="primary"
className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg px-3 py-2')}
render={<a href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel="noreferrer" target="_blank" />}
render={<a href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel="noreferrer" target="_blank" aria-label={t('openInStudio', { ns: 'tools' })} />}
>
<span className="min-w-0 truncate px-0.5 system-sm-medium">{t('openInStudio', { ns: 'tools' })}</span>
<span aria-hidden className="i-ri-arrow-right-up-line size-4 shrink-0" />

View File

@ -47,6 +47,7 @@ const mockResetWorkflowVersionHistory = vi.fn()
const mockInvalidateAppTriggers = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockInvalidateQueries = vi.fn()
const mockSetQueryData = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetLastPublishedHasUserInput = vi.fn()
@ -102,6 +103,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
...actual,
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
setQueryData: mockSetQueryData,
}),
}
})
@ -223,7 +225,7 @@ describe('FeaturesTrigger', () => {
mockUseEdges.mockReturnValue([])
// Set up app store state
useAppStore.setState({ appDetail: { id: 'app-id' } as unknown as App })
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
mockFetchAppDetail.mockResolvedValue({ id: 'app-id', name: 'Updated App' })
mockInvalidateQueries.mockResolvedValue(undefined)
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
})
@ -468,8 +470,13 @@ describe('FeaturesTrigger', () => {
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-id'] })
expect(useAppStore.getState().appDetail).toBeDefined()
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-id'], expect.objectContaining({
name: 'Updated App',
}))
expect(useAppStore.getState().appDetail).toEqual(expect.objectContaining({
name: 'Updated App',
}))
})
})
@ -616,7 +623,7 @@ describe('FeaturesTrigger', () => {
// Arrange
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockInvalidateQueries.mockRejectedValue(new Error('fetch failed'))
mockFetchAppDetail.mockRejectedValueOnce(new Error('fetch failed'))
renderWithToast(<FeaturesTrigger />)

View File

@ -42,6 +42,7 @@ import {
} from '@/app/components/workflow/types'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { fetchAppDetail } from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { useInvalidateAppTriggers } from '@/service/use-tools'
@ -54,6 +55,7 @@ const FeaturesTrigger = () => {
const workflowStore = useWorkflowStore()
const queryClient = useQueryClient()
const appDetail = useAppStore(s => s.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const appID = appDetail?.id
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const canReleaseAndVersion = useHooksStore(s => s.accessControl.canReleaseAndVersion)
@ -148,12 +150,17 @@ const FeaturesTrigger = () => {
const updateAppDetail = useCallback(async () => {
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appID!] })
if (!appID)
return
const res = await fetchAppDetail({ url: '/apps', id: appID })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appID], res)
setAppDetail({ ...res })
}
catch (error) {
console.error(error)
}
}, [appID, queryClient])
}, [appID, queryClient, setAppDetail])
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
// const { validateBeforeRun } = useWorkflowRunValidation()

View File

@ -2,8 +2,10 @@ import type { ComponentProps } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
import { TransferMethod } from '@/types/app'
import { AgentChatRuntime } from '../chat-runtime'
const useChatMock = vi.hoisted(() => vi.fn())
@ -267,4 +269,43 @@ describe('AgentPreviewChat', () => {
await waitFor(() => expect(saveDraftBeforeRun).toHaveBeenCalledTimes(1))
expect(handleSendMock).not.toHaveBeenCalled()
})
it('should keep preview file upload disabled by default', async () => {
renderPreviewChat()
await waitFor(() => expect(useChatMock).toHaveBeenCalled())
const config = useChatMock.mock.calls.at(-1)?.[0]
expect(config.file_upload).toEqual(expect.objectContaining({
enabled: false,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
}))
})
it('should enable build chat file upload when chat features file upload is enabled', async () => {
renderPreviewChat({
agentSoulConfig: {
app_features: {
file_upload: {
enabled: true,
},
},
},
})
await waitFor(() => expect(useChatMock).toHaveBeenCalled())
const config = useChatMock.mock.calls.at(-1)?.[0]
expect(config.file_upload).toEqual(expect.objectContaining({
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
}))
expect(config.file_upload.image).toEqual(expect.objectContaining({
enabled: true,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
}))
})
})

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentPreviewHeader } from '../header'
@ -6,12 +6,14 @@ function renderHeader({
mode = 'preview',
previewEnabled = true,
onModeChange = vi.fn(),
onToggleChatFeatures = vi.fn(),
onOpenVersions = vi.fn(),
onRefresh = vi.fn(),
}: {
mode?: 'build' | 'preview'
previewEnabled?: boolean
onModeChange?: (mode: 'build' | 'preview') => void
onToggleChatFeatures?: () => void
onOpenVersions?: () => void
onRefresh?: () => void
} = {}) {
@ -21,7 +23,7 @@ function renderHeader({
previewEnabled={previewEnabled}
isChatFeaturesOpen={false}
onModeChange={onModeChange}
onToggleChatFeatures={vi.fn()}
onToggleChatFeatures={onToggleChatFeatures}
onOpenVersions={onOpenVersions}
onRefresh={onRefresh}
/>,
@ -43,6 +45,16 @@ describe('AgentPreviewHeader', () => {
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('should show chat features in build mode', async () => {
const user = userEvent.setup()
const onToggleChatFeatures = vi.fn()
renderHeader({ mode: 'build', onToggleChatFeatures })
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.chatFeatures' }))
expect(onToggleChatFeatures).toHaveBeenCalledTimes(1)
})
it('should disable preview mode when preview is unavailable', async () => {
const user = userEvent.setup()
const onModeChange = vi.fn()
@ -52,7 +64,9 @@ describe('AgentPreviewHeader', () => {
onModeChange,
})
await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.rightPanel\.preview/ }))
const modeControl = screen.getByRole('group', { name: 'agentV2.agentDetail.configure.rightPanel.modeLabel' })
await user.click(within(modeControl).getByRole('button', { name: /agentV2\.agentDetail\.configure\.rightPanel\.preview/ }))
expect(onModeChange).not.toHaveBeenCalled()
})

View File

@ -6,7 +6,7 @@ import { AgentChatRuntime } from './chat-runtime'
const buildIconGridCells = Array.from({ length: 16 }, (_, index) => `build-icon-cell-${index}`)
type AgentBuildChatProps = Omit<AgentChatRuntimeProps, 'inputPlaceholder' | 'renderEmptyState'>
type AgentBuildChatProps = Omit<AgentChatRuntimeProps, 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel'>
function AgentBuildChatEmptyState({
inputNode,
@ -43,6 +43,7 @@ export function AgentBuildChat(props: AgentBuildChatProps) {
<AgentChatRuntime
{...props}
inputPlaceholder={t('agentDetail.configure.build.inputPlaceholder')}
sendButtonLabel={t('agentDetail.configure.build.startBuild')}
renderEmptyState={(emptyStateProps: AgentChatRuntimeEmptyStateProps) => (
<AgentBuildChatEmptyState {...emptyStateProps} />
)}

View File

@ -13,6 +13,7 @@ import type {
} from 'react'
import type { FeedbackType, IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Inputs } from '@/models/debug'
@ -31,7 +32,7 @@ import Loading from '@/app/components/base/loading'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useAppContext } from '@/context/app-context'
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
@ -70,6 +71,7 @@ const defaultSystemParameters: ChatConfig['system_parameters'] = {
}
const disabledFileUploadConfig = {
enabled: false,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_types: [],
fileUploadConfig: defaultSystemParameters,
@ -86,6 +88,39 @@ const disabledFileUploadConfig = {
fileUploadConfig: ChatConfig['system_parameters']
}
const defaultFileUploadMethods = [TransferMethod.local_file, TransferMethod.remote_url]
const toPreviewFileUploadConfig = (fileUpload: FileUpload | undefined) => {
if (!fileUpload?.enabled)
return disabledFileUploadConfig
const allowedFileUploadMethods = fileUpload.allowed_file_upload_methods?.length
? fileUpload.allowed_file_upload_methods
: defaultFileUploadMethods
const numberLimits = fileUpload.number_limits ?? fileUpload.image?.number_limits ?? 3
return {
...disabledFileUploadConfig,
...fileUpload,
enabled: true,
allowed_file_types: fileUpload.allowed_file_types?.length
? fileUpload.allowed_file_types
: [SupportUploadFileTypes.image],
allowed_file_upload_methods: allowedFileUploadMethods,
number_limits: numberLimits,
fileUploadConfig: fileUpload.fileUploadConfig ?? defaultSystemParameters,
image: {
...disabledFileUploadConfig.image,
...fileUpload.image,
enabled: fileUpload.image?.enabled ?? true,
transfer_methods: fileUpload.image?.transfer_methods?.length
? fileUpload.image.transfer_methods
: allowedFileUploadMethods,
number_limits: numberLimits,
},
} as ChatConfig['file_upload']
}
const getModelSettings = (agentSoulConfig?: AgentSoulConfig) => agentSoulConfig?.model?.model_settings ?? {}
const toEnabledConfig = (config?: { enabled?: boolean } | null) => ({
@ -385,7 +420,7 @@ const buildChatConfig = ({
},
},
dataset_configs: toLegacyPreviewDatasetConfigs(agentSoulConfig?.knowledge),
file_upload: disabledFileUploadConfig,
file_upload: toPreviewFileUploadConfig(appFeatures.file_upload as FileUpload | undefined),
system_parameters: defaultSystemParameters,
supportCitationHitInfo: true,
}
@ -410,6 +445,7 @@ export type AgentChatRuntimeProps = {
clearChatList: boolean
conversationId?: string | null
inputPlaceholder: string
sendButtonLabel?: string
renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode
onClearChatListChange: (clearChatList: boolean) => void
onConversationIdChange?: (conversationId: string) => void
@ -426,6 +462,7 @@ export function AgentChatRuntime({
clearChatList,
conversationId,
inputPlaceholder,
sendButtonLabel,
renderEmptyState,
onClearChatListChange,
onConversationIdChange,
@ -462,6 +499,7 @@ export function AgentChatRuntime({
conversationId={conversationId}
initialChatTree={initialChatTree}
inputPlaceholder={inputPlaceholder}
sendButtonLabel={sendButtonLabel}
renderEmptyState={renderEmptyState}
onClearChatListChange={onClearChatListChange}
onConversationIdChange={onConversationIdChange}
@ -481,6 +519,7 @@ function AgentPreviewChatSession({
conversationId,
initialChatTree,
inputPlaceholder,
sendButtonLabel,
renderEmptyState,
onClearChatListChange,
onConversationIdChange,
@ -496,6 +535,7 @@ function AgentPreviewChatSession({
conversationId?: string | null
initialChatTree: ChatItemInTree[]
inputPlaceholder: string
sendButtonLabel?: string
renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode
onClearChatListChange: (clearChatList: boolean) => void
onConversationIdChange?: (conversationId: string) => void
@ -602,6 +642,7 @@ function AgentPreviewChatSession({
onSend={doSend}
inputs={inputs}
inputsForm={inputsForm}
sendButtonLabel={sendButtonLabel}
/>
</div>
)
@ -627,6 +668,7 @@ function AgentPreviewChatSession({
isEmptyChat ? 'hidden' : 'px-3 pt-10',
)}
inputPlaceholder={inputPlaceholder}
sendButtonLabel={sendButtonLabel}
showFileUpload={false}
suggestedQuestions={suggestedQuestions}
onSend={doSend}

View File

@ -1,10 +1,31 @@
import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { useDocLink } from '@/context/i18n'
type AgentConfigureRightPanelMode = 'build' | 'preview'
function AgentModeTipSection({
iconClassName,
title,
children,
}: {
iconClassName: string
title: string
children: string
}) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-start gap-1.5">
<span aria-hidden className={cn('size-4 shrink-0 text-text-primary', iconClassName)} />
<div className="system-sm-medium-uppercase text-text-primary">{title}</div>
</div>
<div className="system-xs-regular text-text-secondary">{children}</div>
</div>
)
}
export function AgentPreviewHeader({
mode,
previewEnabled,
@ -25,9 +46,13 @@ export function AgentPreviewHeader({
refreshDisabled?: boolean
}) {
const { t } = useTranslation('agentV2')
const modeTipTitle = t(`agentDetail.configure.rightPanel.${mode}TipTitle`)
const modeTipBody = t(`agentDetail.configure.rightPanel.${mode}TipBody`)
const modeTip = `${modeTipTitle}. ${modeTipBody}`
const docLink = useDocLink()
const buildLabel = t('agentDetail.configure.rightPanel.build')
const buildTipBody = t('agentDetail.configure.rightPanel.buildTipBody')
const previewLabel = t('agentDetail.configure.rightPanel.preview')
const previewTipBody = t('agentDetail.configure.rightPanel.previewTipBody')
const learnMoreLabel = t('agentDetail.configure.rightPanel.learnMore')
const modeTip = `${buildLabel}. ${buildTipBody} ${previewLabel}. ${previewTipBody} ${learnMoreLabel}`
return (
<div className="relative z-1 flex h-12 shrink-0 items-center gap-3 py-2 pr-3 pl-4">
@ -42,7 +67,7 @@ export function AgentPreviewHeader({
aria-label={t('agentDetail.configure.rightPanel.modeLabel')}
>
<SegmentedControlItem<AgentConfigureRightPanelMode> value="build" className="uppercase">
<span aria-hidden className="i-ri-hammer-line size-4" />
<span aria-hidden className="i-custom-vender-agent-v2-configure-build size-4" />
{t('agentDetail.configure.rightPanel.build')}
</SegmentedControlItem>
<SegmentedControlItem<AgentConfigureRightPanelMode>
@ -50,27 +75,37 @@ export function AgentPreviewHeader({
disabled={!previewEnabled}
className="uppercase"
>
<span aria-hidden className="i-custom-vender-other-replay-line size-4" />
<span aria-hidden className="i-custom-vender-agent-v2-configure-preview size-4" />
{t('agentDetail.configure.rightPanel.preview')}
</SegmentedControlItem>
</SegmentedControl>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={modeTip}
className="flex size-5 shrink-0 items-center justify-center rounded-md text-text-quaternary hover:bg-state-base-hover hover:text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-question-line size-4" />
</button>
)}
/>
<TooltipContent className="max-w-64">
<div className="system-xs-semibold text-text-primary">{modeTipTitle}</div>
<div className="mt-1 system-xs-regular text-text-secondary">{modeTipBody}</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={modeTip}
placement="bottom"
sideOffset={2}
className="size-5 rounded-md"
iconClassName="size-4 text-text-tertiary"
popupClassName="w-60 max-w-60 rounded-xl bg-components-tooltip-bg px-4 py-3.5 text-start text-text-secondary backdrop-blur-[5px]"
>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<AgentModeTipSection iconClassName="i-custom-vender-agent-v2-configure-build" title={buildLabel}>
{buildTipBody}
</AgentModeTipSection>
<AgentModeTipSection iconClassName="i-custom-vender-agent-v2-configure-preview" title={previewLabel}>
{previewTipBody}
</AgentModeTipSection>
</div>
<a
href={docLink('/use-dify/build/agent')}
target="_blank"
rel="noopener noreferrer"
className="body-xs-regular text-text-accent hover:underline"
>
{learnMoreLabel}
</a>
</div>
</Infotip>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
@ -82,35 +117,30 @@ export function AgentPreviewHeader({
>
<span aria-hidden className="i-custom-vender-other-replay-line size-4" />
</button>
{mode === 'build'
? (
<button
type="button"
onClick={onOpenVersions}
className="flex size-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
aria-label={t('agentDetail.configure.publishBar.versionHistory')}
>
<span aria-hidden className="i-ri-folder-3-line size-4" />
</button>
)
: (
<>
<SegmentedControlDivider className="mx-1" />
<button
type="button"
aria-pressed={isChatFeaturesOpen}
onClick={onToggleChatFeatures}
className={cn(
'flex h-8 items-center justify-center gap-0.5 rounded-lg px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isChatFeaturesOpen && 'bg-state-base-hover text-text-secondary',
)}
aria-label={t('agentDetail.configure.preview.chatFeatures')}
>
<span aria-hidden className="i-ri-apps-2-add-line size-4" />
<span className="px-0.5 system-sm-medium">{t('agentDetail.configure.preview.chatFeatures')}</span>
</button>
</>
)}
{mode === 'build' && (
<button
type="button"
onClick={onOpenVersions}
className="flex size-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
aria-label={t('agentDetail.configure.publishBar.versionHistory')}
>
<span aria-hidden className="i-ri-folder-3-line size-4" />
</button>
)}
<SegmentedControlDivider className="mx-1" />
<button
type="button"
aria-pressed={isChatFeaturesOpen}
onClick={onToggleChatFeatures}
className={cn(
'flex h-8 items-center justify-center gap-0.5 rounded-lg px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isChatFeaturesOpen && 'bg-state-base-hover text-text-secondary',
)}
aria-label={t('agentDetail.configure.preview.chatFeatures')}
>
<span aria-hidden className="i-ri-apps-2-add-line size-4" />
<span className="px-0.5 system-sm-medium">{t('agentDetail.configure.preview.chatFeatures')}</span>
</button>
</div>
</div>
)

View File

@ -41,6 +41,7 @@ type PanelHarnessProps = {
type?: TagType
value?: Tag[]
tagList?: Tag[]
canBindOrUnbindTags?: boolean
onOpenTagManagement?: () => void
}
@ -52,6 +53,7 @@ const PanelHarness = ({
type = 'app',
value = [appTags[0]!],
tagList = [...appTags, knowledgeTag],
canBindOrUnbindTags,
onOpenTagManagement,
}: PanelHarnessProps) => {
const [selectedTags, setSelectedTags] = useState<Tag[]>(value)
@ -92,6 +94,7 @@ const PanelHarness = ({
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags={canBindOrUnbindTags}
onOpenTagManagement={onOpenTagManagement}
/>
</Combobox>
@ -211,6 +214,31 @@ describe('TagSearchContent', () => {
expect(screen.getByRole('option', { name: /Frontend/i })).toBeInTheDocument()
})
it('does not update selection when neither tag management nor binding permission is available', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = []
render(<PanelHarness />)
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).not.toHaveBeenCalled()
})
it('updates selection with binding capability without tag management permission', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = []
render(<PanelHarness canBindOrUnbindTags />)
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'tag-2' }),
]))
expect(screen.queryByRole('button', { name: i18n.manageTags })).not.toBeInTheDocument()
})
it('renders knowledge tags when the panel type is knowledge', () => {
render(<PanelHarness type="knowledge" value={[]} />)
expect(screen.getByRole('option', { name: /KnowledgeDB/i })).toBeInTheDocument()

View File

@ -90,6 +90,7 @@ vi.mock('../hooks/use-tag-mutations', () => ({
const i18n = {
addTag: 'common.tag.addTag',
noTag: 'common.tag.noTag',
selectorPlaceholder: 'common.tag.selectorPlaceholder',
manageTags: 'common.tag.manageTags',
modifiedSuccessfully: 'common.actionMsg.modifiedSuccessfully',
@ -122,9 +123,16 @@ describe('TagSelector', () => {
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
it('renders the add tag trigger when no current tag is visible in the workspace tag list', () => {
it('renders the no tag trigger when no current tag is visible and binding is unavailable', () => {
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: 0 }]} />)
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
expect(screen.queryByText(i18n.addTag)).not.toBeInTheDocument()
})
it('renders the add tag trigger when no current tag is visible and binding is available', () => {
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: 0 }]} canBindOrUnbindTags />)
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.addTag)).toBeInTheDocument()
})
@ -242,7 +250,7 @@ describe('TagSelector', () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} type="knowledge" value={[]} />)
await user.click(screen.getByRole('combobox', { name: i18n.addTag }))
await user.click(screen.getByRole('combobox', { name: i18n.noTag }))
await user.type(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), 'NewKnowledgeTag')
await user.click(await screen.findByRole('option', { name: /NewKnowledgeTag/i }))
@ -275,6 +283,22 @@ describe('TagSelector', () => {
expect(screen.queryByRole('button', { name: i18n.manageTags })).not.toBeInTheDocument()
})
it('applies added tags with binding capability even without workspace tag management permission', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = []
render(<TagSelector {...defaultProps} canBindOrUnbindTags />)
const trigger = screen.getByRole('combobox', { name: /Frontend/i })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: /Backend/i }))
await user.click(trigger)
await waitFor(() => {
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
})
})
it('does not create new tags when only binding capability is available', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = []

View File

@ -8,9 +8,16 @@ describe('Trigger', () => {
// Rendering behavior for empty and populated states.
describe('Rendering', () => {
it('should render add-tag placeholder when tags are empty', () => {
it('should render no-tag placeholder when tags are empty without binding permission', () => {
render(<TagTrigger tags={[]} />)
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
it('should render add-tag placeholder when tags are empty with binding permission', () => {
render(<TagTrigger tags={[]} canBindOrUnbindTags />)
expect(screen.getByText('common.tag.addTag')).toBeInTheDocument()
})
@ -27,11 +34,12 @@ describe('Trigger', () => {
describe('Props', () => {
it('should update from placeholder to tag badges when tags prop changes', () => {
const { rerender } = render(<TagTrigger tags={[]} />)
expect(screen.getByText('common.tag.addTag')).toBeInTheDocument()
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
rerender(<TagTrigger tags={['Database']} />)
expect(screen.getByText('Database')).toBeInTheDocument()
expect(screen.queryByText('common.tag.noTag')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
})

View File

@ -14,6 +14,7 @@ type TagSearchContentProps = {
onInputValueChange: (value: string) => void
onOpenTagManagement?: () => void
onClose?: () => void
canBindOrUnbindTags?: boolean
}
export const TagSearchContent = ({
@ -22,6 +23,7 @@ export const TagSearchContent = ({
onInputValueChange,
onOpenTagManagement,
onClose,
canBindOrUnbindTags = false,
}: TagSearchContentProps) => {
const { t } = useTranslation()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
@ -56,7 +58,7 @@ export const TagSearchContent = ({
</div>
<ComboboxList className="max-h-58">
{(tag: TagComboboxItem) => {
if (isCreateTagOption(tag)) {
if (isCreateTagOption(tag) && canManageTags) {
return (
<Fragment key={tag.id}>
<ComboboxItem
@ -76,7 +78,11 @@ export const TagSearchContent = ({
}
return (
<ComboboxItem key={tag.id} value={tag}>
<ComboboxItem
key={tag.id}
value={tag}
disabled={!canBindOrUnbindTags && !canManageTags}
>
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>

View File

@ -98,7 +98,10 @@ export const TagSelector = ({
return tagName ? [tagName] : []
})
}, [tagList, value])
const triggerLabel = tagNames.length ? tagNames.join(', ') : t('tag.addTag', { ns: 'common' })
const emptyTriggerLabel = canBindOrUnbindTags
? t('tag.addTag', { ns: 'common' })
: t('tag.noTag', { ns: 'common' })
const triggerLabel = tagNames.length ? tagNames.join(', ') : emptyTriggerLabel
const items = useMemo<TagComboboxItem[]>(() => {
const tagIds = new Set<string>()
@ -227,7 +230,10 @@ export const TagSelector = ({
)}
icon={false}
>
<TagTrigger tags={tagNames} />
<TagTrigger
tags={tagNames}
canBindOrUnbindTags={canBindOrUnbindTags}
/>
</ComboboxTrigger>
<ComboboxContent
placement={placement}
@ -242,6 +248,7 @@ export const TagSelector = ({
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags={canBindOrUnbindTags}
onOpenTagManagement={onOpenTagManagement}
onClose={() => handleOpenChange(false)}
/>

View File

@ -1,17 +1,26 @@
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type TriggerProps = {
tags: string[]
canBindOrUnbindTags?: boolean
}
export const TagTrigger = ({
tags,
canBindOrUnbindTags = false,
}: TriggerProps) => {
const { t } = useTranslation()
const emptyTagLabel = canBindOrUnbindTags
? t('tag.addTag', { ns: 'common' })
: t('tag.noTag', { ns: 'common' })
return (
<div
className="flex w-full cursor-pointer items-center gap-1 overflow-hidden rounded-lg p-1 hover:bg-state-base-hover"
className={cn(
'flex w-full cursor-pointer items-center gap-1 overflow-hidden rounded-lg p-1 hover:bg-state-base-hover',
!canBindOrUnbindTags && 'pointer-events-none opacity-50',
)}
role={tags.length ? 'list' : undefined}
>
{!tags.length
@ -19,7 +28,7 @@ export const TagTrigger = ({
<div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75">
<span aria-hidden="true" className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" />
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
{t('tag.addTag', { ns: 'common' })}
{emptyTagLabel}
</div>
</div>
)

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "صِف ما تريده وسيتم ملء النموذج على اليسار أثناء المحادثة.",
"agentDetail.configure.build.empty.title": "ابنِ وكيلك عبر الدردشة",
"agentDetail.configure.build.inputPlaceholder": "صِف ما يجب أن يفعله وكيلك",
"agentDetail.configure.build.startBuild": "ابدأ البناء",
"agentDetail.configure.chatFeatures.description": "شكّل تجربة الدردشة للمستخدم النهائي على Web app وأسطح الدردشة.",
"agentDetail.configure.chatFeatures.title": "ميزات الدردشة",
"agentDetail.configure.files.add": "إضافة ملف",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} سير عمل",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} سير عمل",
"agentDetail.configure.rightPanel.build": "بناء",
"agentDetail.configure.rightPanel.buildTipBody": "صف هدفك، ووجّه وكيلك لتثبيت الأدوات ومصادقتها، أو مرّره عبر حالة مثال كاملة. بعد الحفظ، ستُدمج تعليماتك في الوكيل.",
"agentDetail.configure.rightPanel.buildTipBody": "يضبط Build الوكيل عبر الدردشة. صف ما تريده وسيملأ الإعدادات على اليسار.",
"agentDetail.configure.rightPanel.buildTipTitle": "ابنِ وكيلك عبر الدردشة",
"agentDetail.configure.rightPanel.learnMore": "معرفة المزيد",
"agentDetail.configure.rightPanel.modeLabel": "وضع تكوين الوكيل",
"agentDetail.configure.rightPanel.preview": "معاينة",
"agentDetail.configure.rightPanel.previewTipBody": "اختبره كمستخدم. لن يؤثر تفاعلك في طريقة تصرف الوكيل لاحقًا.",
"agentDetail.configure.rightPanel.previewTipBody": "يشغل Preview الوكيل المكتمل كما سيراه المستخدمون، مع ردود واضحة وميزات الدردشة.",
"agentDetail.configure.rightPanel.previewTipTitle": "عاين وكيلك",
"agentDetail.configure.skills.add": "إضافة مهارة",
"agentDetail.configure.skills.detail.contentRegion": "محتوى تفاصيل المهارة",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "اتصل بخوادم MCP وأدرها لمنح تطبيقاتك إمكانية الوصول إلى الأدوات والخدمات الخارجية.",
"members.adminTip": "يمكنه بناء التطبيقات وإدارة إعدادات الفريق",
"members.alreadyInTeam": "موجود بالفعل في الفريق",
"members.alreadyInTeamTip": "هؤلاء المستخدمون لديهم بالفعل إمكانية الوصول إلى مساحة العمل هذه.",
"members.assignRoles": "تعيين الأدوار",
"members.assignRolesModal.description": "اختر الأدوار لتعيينها لهذا العضو. سيتم دمج جميع الصلاحيات من الأدوار المختارة.",
"members.assignRolesModal.selectedCount": "{{count}} مختار",
"members.assignRolesModal.singleDescription": "حدد دورًا واحدًا لتعيينه لهذا العضو.",
"members.assignRolesModal.title": "تعيين الأدوار",
"members.datasetOperatorTip": "يمكنه إدارة قاعدة المعرفة فقط",
"members.editRole": "Edit Role",
"members.editorTip": "يمكنه بناء وتعديل التطبيقات",
"members.email": "البريد الإلكتروني",
"members.emailInvalid": "تنسيق البريد الإلكتروني غير صالح",
"members.emailNotSetup": "لم يتم إعداد خادم البريد الإلكتروني، لذا لا يمكن إرسال رسائل بريد إلكتروني للدعوة. يرجى إخطار المستخدمين برابط الدعوة الذي سيتم إصداره بعد الدعوة بدلاً من ذلك.",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "يمكنهم الوصول إلى بيانات فريقك مباشرة بعد تسجيل الدخول.",
"members.invitedAsRole": "تمت الدعوة كـ {{role}}",
"members.lastActive": "آخر نشاط",
"members.memberActions": "إجراءات العضو",
"members.memberDetails.assign": "تعيين",
"members.memberDetails.assignedRole": "الدور المعيّن",
"members.memberDetails.assignedRoles": "الأدوار المعينة",
"members.memberDetails.customGroup": "مخصص",
"members.memberDetails.generalGroup": "عام",
"members.memberDetails.openAria": "فتح تفاصيل العضو لـ {{name}}",
"members.memberDetails.roleActionsAria": "فتح الإجراءات لدور {{role}}",
"members.memberDetails.roleNoPermissionSummary": "الدور الحالي ليس له صلاحيات.",
"members.memberDetails.rolePermissionSummary": "{{role}} يمكنه <permissionList>{{permissions}}</permissionList>",
"members.memberDetails.title": "تفاصيل العضو",
"members.name": "الاسم",
"members.noNewInvitationsSent": "لم يتم إرسال دعوات جديدة",
"members.normalTip": "يمكنه استخدام التطبيقات فقط، ولا يمكنه بناء التطبيقات",
"members.ok": "موافق",
"members.pending": "قيد الانتظار...",
"members.removeFromTeam": "إزالة من الفريق",
"members.removeFromTeamConfirmDescription": "يرجى تأكيد إزالة هذا العضو. لا يمكن التراجع عن هذا الإجراء.",
"members.removeFromTeamConfirmTitle": "إزالة {{memberName}} من الفريق",
"members.role": "الأدوار",
"members.roles": "الأدوار",
"members.selectRole": "اختر دورًا",
"members.sendInvite": "إرسال دعوة",
"members.transferModal.codeLabel": "رمز التحقق",
"members.transferModal.codePlaceholder": "الصق الرمز المكون من 6 أرقام",
@ -425,6 +450,7 @@
"operation.learnMore": "تعرف على المزيد",
"operation.log": "سجل",
"operation.more": "المزيد",
"operation.moreActions": "المزيد من الإجراءات",
"operation.no": "لا",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "لم يتم العثور على {{content}}",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "التكاملات",
"settings.members": "الأعضاء",
"settings.permissionSet": "مجموعة الأذونات",
"settings.permissionSetDescription": "قم بتكوين مجموعات الأذونات لاستخدامها مع التطبيقات وقواعد المعرفة. مجموعة الأذونات هي مجموعة قابلة لإعادة الاستخدام من أذونات عمليات الموارد يمكن تعيينها للأعضاء لموارد محددة.",
"settings.preferences": "Preferences",
"settings.provider": "مزود النموذج",
"settings.resourceAccess": "الوصول إلى الموارد",
"settings.resourceAccessDescription": "قم بتكوين قواعد الصلاحيات التي يمكن للتطبيقات وقواعد المعرفة استخدامها. يمكن تعيين قواعد الصلاحيات للأعضاء في تكوينات وصول محددة للموارد.",
"settings.rolesAndPermissions": "الأدوار والصلاحيات",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

View File

@ -23,6 +23,7 @@
"form.numberOfKeywords": "عدد الكلمات الرئيسية",
"form.onSearchResults": "لا يوجد أعضاء يطابقون استعلام البحث الخاص بك.\nحاول البحث مرة أخرى.",
"form.permissions": "أذونات",
"form.permissionsAccessConfig": "الانتقال إلى تكوين الوصول",
"form.permissionsAllMember": "جميع أعضاء الفريق",
"form.permissionsInvitedMembers": "أعضاء الفريق الجزئيين",
"form.permissionsOnlyMe": "أنا فقط",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "جميع الأعضاء الذين لديهم أذونات الأدوار",
"accessRule.allPermittedMembersDescription": "يمكن للأعضاء الذين لديهم أذونات أدوار مطابقة الوصول إلى هذا المورد.",
"accessRule.appDescription": "تحكم في من يُفتح له هذا التطبيق. لا يزال الأعضاء بحاجة إلى أذونات الأدوار لعرضه أو تشغيله.",
"accessRule.appTitle": "قواعد الوصول إلى التطبيق",
"accessRule.appTitle": "مجموعة أذونات التطبيق",
"accessRule.changeOpenScopeDescription": "سيؤدي تغيير نطاق الفتح إلى إعادة تعيين جميع إعدادات الأذونات الفردية لهذا المورد. ستحتاج إلى إضافة أذونات خاصة بالأعضاء مرة أخرى بعد التبديل.",
"accessRule.changeOpenScopeTitle": "تغيير نطاق فتح المورد؟",
"accessRule.collapseSection": "طي {{title}}",
"accessRule.copied": "تم نسخ قاعدة الوصول بنجاح",
"accessRule.created": "تم إنشاء قاعدة الوصول بنجاح",
"accessRule.datasetDescription": "تحكم في من تُفتح له قاعدة المعرفة هذه. لا يزال الأعضاء بحاجة إلى أذونات الأدوار لعرضها أو تشغيلها.",
"accessRule.datasetTitle": "قواعد الوصول إلى قاعدة المعرفة",
"accessRule.datasetTitle": "مجموعة أذونات قاعدة المعرفة",
"accessRule.defaultPermission": "حسب أذونات الأدوار",
"accessRule.deleteDescription": "سيتم حذف قاعدة الوصول هذه نهائيًا وإزالتها من قائمة تفويض المورد.",
"accessRule.deleteTitle": "حذف \"{{name}}\"؟",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "Beschreiben Sie, was Sie möchten, und das Formular links wird während des Chats ausgefüllt.",
"agentDetail.configure.build.empty.title": "Agent per Chat erstellen",
"agentDetail.configure.build.inputPlaceholder": "Beschreiben Sie, was Ihr Agent tun soll",
"agentDetail.configure.build.startBuild": "Build starten",
"agentDetail.configure.chatFeatures.description": "Gestalten Sie das Chat-Erlebnis für Endnutzer in Ihrer Webapp und in Chat-Oberflächen.",
"agentDetail.configure.chatFeatures.title": "Chat-Funktionen",
"agentDetail.configure.files.add": "Datei hinzufügen",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} Workflow",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} Workflows",
"agentDetail.configure.rightPanel.build": "Erstellen",
"agentDetail.configure.rightPanel.buildTipBody": "Beschreibe dein Ziel, weise deinen Agenten an, Tools zu installieren und zu authentifizieren, oder führe ihn durch einen vollständigen Beispielfall. Nach dem Speichern werden deine Anweisungen in den Agenten übernommen.",
"agentDetail.configure.rightPanel.buildTipBody": "Build richtet den Agenten per Chat ein. Beschreibe, was du möchtest, und die Einrichtung links wird ausgefüllt.",
"agentDetail.configure.rightPanel.buildTipTitle": "Baue deinen Agenten per Chat",
"agentDetail.configure.rightPanel.learnMore": "Mehr erfahren",
"agentDetail.configure.rightPanel.modeLabel": "Agent-Konfigurationsmodus",
"agentDetail.configure.rightPanel.preview": "Vorschau",
"agentDetail.configure.rightPanel.previewTipBody": "Teste ihn wie ein Benutzer. Deine Interaktion beeinflusst nicht, wie sich der Agent später verhält.",
"agentDetail.configure.rightPanel.previewTipBody": "Preview führt den fertigen Agenten so aus, wie deine Benutzer ihn sehen, mit klaren Antworten und Chat-Funktionen.",
"agentDetail.configure.rightPanel.previewTipTitle": "Agenten vorschauen",
"agentDetail.configure.skills.add": "Skill hinzufügen",
"agentDetail.configure.skills.detail.contentRegion": "Skill-Detailinhalt",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "Verbinde und verwalte MCP-Server, damit deine Apps auf externe Tools und Dienste zugreifen können.",
"members.adminTip": "Kann Apps erstellen & Team-Einstellungen verwalten",
"members.alreadyInTeam": "Bereits im Team",
"members.alreadyInTeamTip": "Diese Benutzer haben bereits Zugriff auf diesen Arbeitsbereich.",
"members.assignRoles": "Rollen zuweisen",
"members.assignRolesModal.description": "Wählen Sie Rollen aus, die diesem Mitglied zugewiesen werden sollen. Alle Berechtigungen der ausgewählten Rollen werden kombiniert.",
"members.assignRolesModal.selectedCount": "{{count}} ausgewählt",
"members.assignRolesModal.singleDescription": "Wählen Sie eine Rolle aus, die diesem Mitglied zugewiesen werden soll.",
"members.assignRolesModal.title": "Rollen zuweisen",
"members.datasetOperatorTip": "Kann die Wissensdatenbank nur verwalten",
"members.editRole": "Rolle bearbeiten",
"members.editorTip": "Kann Apps erstellen und bearbeiten",
"members.email": "E-Mail",
"members.emailInvalid": "Ungültiges E-Mail-Format",
"members.emailNotSetup": "E-Mail-Server ist nicht eingerichtet, daher können keine Einladungs-E-Mails versendet werden. Bitte informieren Sie die Benutzer über den Einladungslink, der nach der Einladung ausgestellt wird.",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "Sie können direkt nach der Anmeldung auf Ihre Teamdaten zugreifen.",
"members.invitedAsRole": "Eingeladen als {{role}}",
"members.lastActive": "ZULETZT AKTIV",
"members.memberActions": "Mitgliederaktionen",
"members.memberDetails.assign": "Zuweisen",
"members.memberDetails.assignedRole": "Zugewiesene Rolle",
"members.memberDetails.assignedRoles": "Zugewiesene Rollen",
"members.memberDetails.customGroup": "BENUTZERDEFINIERT",
"members.memberDetails.generalGroup": "ALLGEMEIN",
"members.memberDetails.openAria": "Mitgliederdetails für {{name}} öffnen",
"members.memberDetails.roleActionsAria": "Aktionen für die Rolle {{role}} öffnen",
"members.memberDetails.roleNoPermissionSummary": "Die aktuelle Rolle hat keine Berechtigungen.",
"members.memberDetails.rolePermissionSummary": "{{role}} kann <permissionList>{{permissions}}</permissionList>",
"members.memberDetails.title": "Mitgliederdetails",
"members.name": "NAME",
"members.noNewInvitationsSent": "Keine neuen Einladungen gesendet",
"members.normalTip": "Kann nur Apps verwenden, kann keine Apps erstellen",
"members.ok": "OK",
"members.pending": "Ausstehend...",
"members.removeFromTeam": "Vom Team entfernen",
"members.removeFromTeamConfirmDescription": "Bestätige, dass dieses Mitglied entfernt werden soll. Diese Aktion kann nicht rückgängig gemacht werden.",
"members.removeFromTeamConfirmTitle": "{{memberName}} aus dem Team entfernen",
"members.role": "ROLLEN",
"members.roles": "ROLLEN",
"members.selectRole": "Wählen Sie eine Rolle aus",
"members.sendInvite": "Einladung senden",
"members.transferModal.codeLabel": "Bestätigungscode",
"members.transferModal.codePlaceholder": "Geben Sie den 6-stelligen Code ein",
@ -425,6 +450,7 @@
"operation.learnMore": "Mehr erfahren",
"operation.log": "Protokoll",
"operation.more": "Mehr",
"operation.moreActions": "Weitere Aktionen",
"operation.no": "Nein",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Es wurden keine {{content}} gefunden",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "Integrationen",
"settings.members": "Mitglieder",
"settings.permissionSet": "Berechtigungssatz",
"settings.permissionSetDescription": "Konfigurieren Sie Berechtigungssätze zur Verwendung mit Apps und Wissensdatenbanken. Ein Berechtigungssatz ist eine wiederverwendbare Sammlung von Berechtigungen für Ressourcenoperationen, die Mitgliedern für bestimmte Ressourcen zugewiesen werden kann.",
"settings.preferences": "Preferences",
"settings.provider": "Modellanbieter",
"settings.resourceAccess": "Ressourcenzugriff",
"settings.resourceAccessDescription": "Konfigurieren Sie Berechtigungsregeln, die Apps und Wissensdatenbanken verwenden können. Berechtigungsregeln können Mitgliedern in bestimmten Ressourcenzugriffskonfigurationen zugewiesen werden.",
"settings.rolesAndPermissions": "Rollen & Berechtigungen",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

View File

@ -23,6 +23,7 @@
"form.numberOfKeywords": "Anzahl der Schlüsselwörter",
"form.onSearchResults": "Kein Mitglied stimmt mit Ihrer Suchanfrage überein.\nVersuchen Sie Ihre Suche erneut.",
"form.permissions": "Berechtigungen",
"form.permissionsAccessConfig": "Zur Zugriffskonfiguration wechseln",
"form.permissionsAllMember": "Alle Teammitglieder",
"form.permissionsInvitedMembers": "Teilweise Teammitglieder",
"form.permissionsOnlyMe": "Nur ich",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "Alle Mitglieder mit Rollenberechtigungen",
"accessRule.allPermittedMembersDescription": "Mitglieder mit passenden Rollenberechtigungen können auf diese Ressource zugreifen.",
"accessRule.appDescription": "Steuern Sie, für wen diese App geöffnet ist. Mitglieder benötigen dennoch Rollenberechtigungen, um sie anzuzeigen oder zu bedienen.",
"accessRule.appTitle": "App-Zugriffsregeln",
"accessRule.appTitle": "App-Berechtigungssatz",
"accessRule.changeOpenScopeDescription": "Das Ändern des Freigabebereichs setzt alle individuellen Berechtigungseinstellungen für diese Ressource zurück. Nach dem Wechsel müssen Sie mitgliederspezifische Berechtigungen erneut hinzufügen.",
"accessRule.changeOpenScopeTitle": "Freigabebereich der Ressource ändern?",
"accessRule.collapseSection": "{{title}} einklappen",
"accessRule.copied": "Zugriffsregel erfolgreich kopiert",
"accessRule.created": "Zugriffsregel erfolgreich erstellt",
"accessRule.datasetDescription": "Steuern Sie, für wen diese Wissensdatenbank geöffnet ist. Mitglieder benötigen dennoch Rollenberechtigungen, um sie anzuzeigen oder zu bedienen.",
"accessRule.datasetTitle": "Zugriffsregeln der Wissensdatenbank",
"accessRule.datasetTitle": "Wissensdatenbank-Berechtigungssatz",
"accessRule.defaultPermission": "Nach Rollenberechtigungen",
"accessRule.deleteDescription": "Diese Zugriffsregel wird dauerhaft gelöscht und aus der Ressourcen-Autorisierungsliste entfernt.",
"accessRule.deleteTitle": "\"{{name}}\" löschen?",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "Describe what you want and it fills in the form on the left as you go.",
"agentDetail.configure.build.empty.title": "Build your agent by chatting",
"agentDetail.configure.build.inputPlaceholder": "Describe what your agent should do",
"agentDetail.configure.build.startBuild": "Start build",
"agentDetail.configure.chatFeatures.description": "Shape the end-user chat experience on your web app and chat surfaces.",
"agentDetail.configure.chatFeatures.title": "Chat Features",
"agentDetail.configure.files.add": "Add file",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} workflow",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} workflows",
"agentDetail.configure.rightPanel.build": "Build",
"agentDetail.configure.rightPanel.buildTipBody": "Describe your goal, instruct your agent to install and authenticate tools, or walk it through a complete sample case. Once saved, your instructions will be welded into the agent.",
"agentDetail.configure.rightPanel.buildTipBody": "Build sets up the agent by chatting. Describe what you want and it fills in the setup on the left.",
"agentDetail.configure.rightPanel.buildTipTitle": "Build your agent via chat",
"agentDetail.configure.rightPanel.learnMore": "Learn more",
"agentDetail.configure.rightPanel.modeLabel": "Agent configuration mode",
"agentDetail.configure.rightPanel.preview": "Preview",
"agentDetail.configure.rightPanel.previewTipBody": "Test it out as a user. Your interaction will not affect how the agent behaves going forward.",
"agentDetail.configure.rightPanel.previewTipBody": "Preview runs the finished agent the way your users will see it, with clean replies and chat features.",
"agentDetail.configure.rightPanel.previewTipTitle": "Preview your agent",
"agentDetail.configure.skills.add": "Add skill",
"agentDetail.configure.skills.detail.contentRegion": "Skill detail content",

View File

@ -213,12 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "Connect and manage MCP servers to give your apps access to external tools and services.",
"members.adminTip": "Can build apps & manage team settings",
"members.alreadyInTeam": "Already in team",
"members.alreadyInTeamTip": "These users already have access to this workspace.",
"members.assignRoles": "Assign Roles",
"members.assignRolesModal.description": "Select roles to assign to this member. All permissions from selected roles will be combined.",
"members.assignRolesModal.selectedCount": "{{count}} selected",
"members.assignRolesModal.singleDescription": "Select one role to assign to this member.",
"members.assignRolesModal.title": "Assign Roles",
"members.datasetOperatorTip": "Only can manage the knowledge base",
"members.editRole": "Edit Role",
"members.editorTip": "Can build & edit apps",
"members.email": "Email",
"members.emailInvalid": "Invalid Email Format",
"members.emailNotSetup": "Email server is not set up, so invitation emails cannot be sent. Please notify users of the invitation link that will be issued after invitation instead.",
@ -234,6 +239,7 @@
"members.lastActive": "LAST ACTIVE",
"members.memberActions": "Member actions",
"members.memberDetails.assign": "Assign",
"members.memberDetails.assignedRole": "Assigned Role",
"members.memberDetails.assignedRoles": "Assigned Roles",
"members.memberDetails.customGroup": "CUSTOMIZED",
"members.memberDetails.generalGroup": "GENERAL",
@ -244,10 +250,14 @@
"members.memberDetails.title": "Member Details",
"members.name": "NAME",
"members.noNewInvitationsSent": "No new invitations sent",
"members.normalTip": "Only can use apps, can not build apps",
"members.ok": "OK",
"members.pending": "Pending...",
"members.removeFromTeam": "Remove from team",
"members.role": "ROLES",
"members.removeFromTeamConfirmDescription": "Confirm removing this member. This action cannot be undone.",
"members.removeFromTeamConfirmTitle": "Remove {{memberName}} from team",
"members.role": "ROLE",
"members.roles": "ROLES",
"members.selectRole": "Select a role",
"members.sendInvite": "Send Invite",
"members.transferModal.codeLabel": "Verification code",
@ -511,6 +521,8 @@
"settings.extension": "Extension",
"settings.integrations": "Integrations",
"settings.members": "Members",
"settings.permissionSet": "Permission Set",
"settings.permissionSetDescription": "Configure permission sets for use with applications and knowledge bases. A permission set is a reusable collection of resource operation permissions that can be assigned to members for specific resources.",
"settings.preferences": "Preferences",
"settings.provider": "Model Provider",
"settings.resourceAccess": "Resource Access",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "All members with role permissions",
"accessRule.allPermittedMembersDescription": "Members with matching role permissions can access this resource.",
"accessRule.appDescription": "Control who this app is open to. Members still need role permissions to view or operate it.",
"accessRule.appTitle": "App Access Rules",
"accessRule.appTitle": "App Permission Set",
"accessRule.changeOpenScopeDescription": "Changing the open scope will reset all individual permission settings for this resource. You'll need to add member-specific permissions again after switching.",
"accessRule.changeOpenScopeTitle": "Change resource open scope?",
"accessRule.collapseSection": "Collapse {{title}}",
"accessRule.copied": "Access rule copied successfully",
"accessRule.created": "Access rule created successfully",
"accessRule.datasetDescription": "Control who this knowledge base is open to. Members still need role permissions to view or operate it.",
"accessRule.datasetTitle": "Knowledge Base Access Rules",
"accessRule.datasetTitle": "Knowledge Base Permission Set",
"accessRule.defaultPermission": "By role permissions",
"accessRule.deleteDescription": "This access rule will be permanently deleted and removed from the resource authorization list.",
"accessRule.deleteTitle": "Delete \"{{name}}\"?",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "Describe lo que quieres y se irá completando el formulario de la izquierda mientras avanzas.",
"agentDetail.configure.build.empty.title": "Crea tu agente chateando",
"agentDetail.configure.build.inputPlaceholder": "Describe qué debe hacer tu agente",
"agentDetail.configure.build.startBuild": "Iniciar compilación",
"agentDetail.configure.chatFeatures.description": "Da forma a la experiencia de chat del usuario final en tu webapp y superficies de chat.",
"agentDetail.configure.chatFeatures.title": "Funciones de chat",
"agentDetail.configure.files.add": "Agregar archivo",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} flujo de trabajo",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} flujos de trabajo",
"agentDetail.configure.rightPanel.build": "Crear",
"agentDetail.configure.rightPanel.buildTipBody": "Describe tu objetivo, indica al agente que instale y autentique herramientas, o guíalo por un caso de ejemplo completo. Una vez guardadas, tus instrucciones quedarán incorporadas al agente.",
"agentDetail.configure.rightPanel.buildTipBody": "Build configura el agente mediante chat. Describe lo que quieres y completará la configuración de la izquierda.",
"agentDetail.configure.rightPanel.buildTipTitle": "Construye tu agente mediante chat",
"agentDetail.configure.rightPanel.learnMore": "Más información",
"agentDetail.configure.rightPanel.modeLabel": "Modo de configuración del agente",
"agentDetail.configure.rightPanel.preview": "Vista previa",
"agentDetail.configure.rightPanel.previewTipBody": "Pruébalo como usuario. Tu interacción no afectará al comportamiento futuro del agente.",
"agentDetail.configure.rightPanel.previewTipBody": "Preview ejecuta el agente terminado como lo verán tus usuarios, con respuestas claras y funciones de chat.",
"agentDetail.configure.rightPanel.previewTipTitle": "Previsualiza tu agente",
"agentDetail.configure.skills.add": "Agregar habilidad",
"agentDetail.configure.skills.detail.contentRegion": "Contenido de los detalles de la habilidad",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "Conecta y gestiona servidores MCP para dar a tus apps acceso a herramientas y servicios externos.",
"members.adminTip": "Puede crear aplicaciones y administrar configuraciones del equipo",
"members.alreadyInTeam": "Ya está en el equipo",
"members.alreadyInTeamTip": "Estos usuarios ya tienen acceso a este espacio de trabajo.",
"members.assignRoles": "Asignar roles",
"members.assignRolesModal.description": "Selecciona los roles que se asignarán a este miembro. Se combinarán todos los permisos de los roles seleccionados.",
"members.assignRolesModal.selectedCount": "{{count}} seleccionados",
"members.assignRolesModal.singleDescription": "Selecciona un rol para asignarlo a este miembro.",
"members.assignRolesModal.title": "Asignar roles",
"members.datasetOperatorTip": "Solo puede administrar la base de conocimiento",
"members.editRole": "Editar rol",
"members.editorTip": "Puede crear y editar apps",
"members.email": "Correo electrónico",
"members.emailInvalid": "Formato de correo electrónico inválido",
"members.emailNotSetup": "El servidor de correo no está configurado, por lo que no se pueden enviar correos de invitación. En su lugar, notifique a los usuarios el enlace de invitación que se emitirá después de la invitación.",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "Pueden acceder a tus datos del equipo directamente después de iniciar sesión.",
"members.invitedAsRole": "Invitado como {{role}}",
"members.lastActive": "ÚLTIMA ACTIVIDAD",
"members.memberActions": "Acciones del miembro",
"members.memberDetails.assign": "Asignar",
"members.memberDetails.assignedRole": "Rol asignado",
"members.memberDetails.assignedRoles": "Roles asignados",
"members.memberDetails.customGroup": "PERSONALIZADO",
"members.memberDetails.generalGroup": "GENERAL",
"members.memberDetails.openAria": "Abrir detalles del miembro de {{name}}",
"members.memberDetails.roleActionsAria": "Abrir acciones para el rol {{role}}",
"members.memberDetails.roleNoPermissionSummary": "El rol actual no tiene permisos.",
"members.memberDetails.rolePermissionSummary": "{{role}} puede <permissionList>{{permissions}}</permissionList>",
"members.memberDetails.title": "Detalles del miembro",
"members.name": "NOMBRE",
"members.noNewInvitationsSent": "No se han enviado nuevas invitaciones",
"members.normalTip": "Solo puede usar aplicaciones, no puede crear aplicaciones",
"members.ok": "OK",
"members.pending": "Pendiente...",
"members.removeFromTeam": "Eliminar del espacio de trabajo",
"members.removeFromTeamConfirmDescription": "Confirma que quieres eliminar a este miembro. Esta acción no se puede deshacer.",
"members.removeFromTeamConfirmTitle": "Eliminar a {{memberName}} del equipo",
"members.role": "ROLES",
"members.roles": "ROLES",
"members.selectRole": "Selecciona un rol",
"members.sendInvite": "Enviar invitación",
"members.transferModal.codeLabel": "Código de verificación",
"members.transferModal.codePlaceholder": "Pegue el código de 6 dígitos",
@ -425,6 +450,7 @@
"operation.learnMore": "Aprender más",
"operation.log": "Registro",
"operation.more": "Más",
"operation.moreActions": "Más acciones",
"operation.no": "No",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "No se encontraron {{content}}",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "Integraciones",
"settings.members": "Miembros",
"settings.permissionSet": "Conjunto de permisos",
"settings.permissionSetDescription": "Configura conjuntos de permisos para usarlos con aplicaciones y bases de conocimiento. Un conjunto de permisos es una colección reutilizable de permisos de operaciones sobre recursos que se puede asignar a miembros para recursos específicos.",
"settings.preferences": "Preferences",
"settings.provider": "Proveedor de Modelo",
"settings.resourceAccess": "Acceso a recursos",
"settings.resourceAccessDescription": "Configura las reglas de permisos que pueden usar las aplicaciones y las bases de conocimiento. Las reglas de permisos se pueden asignar a los miembros en configuraciones específicas de acceso a recursos.",
"settings.rolesAndPermissions": "Roles y permisos",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

View File

@ -23,6 +23,7 @@
"form.numberOfKeywords": "Número de palabras clave",
"form.onSearchResults": "Ningún miembro coincide con su consulta de búsqueda.\nIntente su búsqueda nuevamente.",
"form.permissions": "Permisos",
"form.permissionsAccessConfig": "Ir a la configuración de acceso",
"form.permissionsAllMember": "Todos los miembros del equipo",
"form.permissionsInvitedMembers": "Miembros del equipo invitados",
"form.permissionsOnlyMe": "Solo yo",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "Todos los miembros con permisos de rol",
"accessRule.allPermittedMembersDescription": "Los miembros con permisos de rol coincidentes pueden acceder a este recurso.",
"accessRule.appDescription": "Controla a quién está abierta esta app. Los miembros todavía necesitan permisos de rol para verla u operarla.",
"accessRule.appTitle": "Reglas de acceso de la app",
"accessRule.appTitle": "Conjunto de permisos de app",
"accessRule.changeOpenScopeDescription": "Cambiar el ámbito de apertura restablecerá todos los ajustes de permisos individuales para este recurso. Tendrás que volver a añadir los permisos específicos de cada miembro después de cambiarlo.",
"accessRule.changeOpenScopeTitle": "¿Cambiar el ámbito de apertura del recurso?",
"accessRule.collapseSection": "Contraer {{title}}",
"accessRule.copied": "Regla de acceso copiada correctamente",
"accessRule.created": "Regla de acceso creada correctamente",
"accessRule.datasetDescription": "Controla a quién está abierta esta base de conocimiento. Los miembros todavía necesitan permisos de rol para verla u operarla.",
"accessRule.datasetTitle": "Reglas de acceso de la base de conocimiento",
"accessRule.datasetTitle": "Conjunto de permisos de base de conocimiento",
"accessRule.defaultPermission": "Por permisos de rol",
"accessRule.deleteDescription": "Esta regla de acceso se eliminará de forma permanente y se quitará de la lista de autorización del recurso.",
"accessRule.deleteTitle": "¿Eliminar \"{{name}}\"?",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "آنچه می‌خواهید را توضیح دهید تا فرم سمت چپ در طول گفتگو تکمیل شود.",
"agentDetail.configure.build.empty.title": "عامل خود را با چت بسازید",
"agentDetail.configure.build.inputPlaceholder": "توضیح دهید عامل شما باید چه کاری انجام دهد",
"agentDetail.configure.build.startBuild": "شروع ساخت",
"agentDetail.configure.chatFeatures.description": "تجربه چت کاربر نهایی را در Web app و سطوح چت خود شکل دهید.",
"agentDetail.configure.chatFeatures.title": "ویژگی‌های چت",
"agentDetail.configure.files.add": "افزودن فایل",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} گردش کار",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} گردش کار",
"agentDetail.configure.rightPanel.build": "ساخت",
"agentDetail.configure.rightPanel.buildTipBody": "هدف خود را توضیح دهید، از عامل بخواهید ابزارها را نصب و احراز هویت کند، یا او را در یک نمونه کامل راهنمایی کنید. پس از ذخیره، دستورالعمل‌های شما در عامل ادغام می‌شود.",
"agentDetail.configure.rightPanel.buildTipBody": "Build عامل را از طریق گفتگو تنظیم می‌کند. آنچه می‌خواهید را توضیح دهید تا تنظیمات سمت چپ تکمیل شود.",
"agentDetail.configure.rightPanel.buildTipTitle": "عامل خود را از طریق گفتگو بسازید",
"agentDetail.configure.rightPanel.learnMore": "بیشتر بدانید",
"agentDetail.configure.rightPanel.modeLabel": "حالت پیکربندی عامل",
"agentDetail.configure.rightPanel.preview": "پیش‌نمایش",
"agentDetail.configure.rightPanel.previewTipBody": "آن را مانند یک کاربر امتحان کنید. تعامل شما بر رفتار آینده عامل تأثیری نخواهد گذاشت.",
"agentDetail.configure.rightPanel.previewTipBody": "Preview عامل تکمیل‌شده را همان‌طور که کاربران می‌بینند اجرا می‌کند، با پاسخ‌های تمیز و قابلیت‌های گفتگو.",
"agentDetail.configure.rightPanel.previewTipTitle": "پیش‌نمایش عامل",
"agentDetail.configure.skills.add": "افزودن مهارت",
"agentDetail.configure.skills.detail.contentRegion": "محتوای جزئیات مهارت",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "سرورهای MCP را وصل و مدیریت کنید تا برنامه‌های شما به ابزارها و سرویس‌های خارجی دسترسی داشته باشند.",
"members.adminTip": "می‌تواند برنامه‌ها را بسازد و تنظیمات تیم را مدیریت کند",
"members.alreadyInTeam": "در حال حاضر در تیم است",
"members.alreadyInTeamTip": "این کاربران از قبل به این فضای کاری دسترسی دارند.",
"members.assignRoles": "تخصیص نقش‌ها",
"members.assignRolesModal.description": "نقش‌هایی را برای تخصیص به این عضو انتخاب کنید. تمام مجوزهای نقش‌های انتخاب‌شده با هم ترکیب خواهند شد.",
"members.assignRolesModal.selectedCount": "{{count}} انتخاب‌شده",
"members.assignRolesModal.singleDescription": "یک نقش را برای اختصاص به این عضو انتخاب کنید.",
"members.assignRolesModal.title": "تخصیص نقش‌ها",
"members.datasetOperatorTip": "فقط می‌تواند پایگاه دانش را مدیریت کند",
"members.editRole": "ویرایش نقش",
"members.editorTip": "می‌تواند برنامه‌ها را بسازد و ویرایش کند",
"members.email": "ایمیل",
"members.emailInvalid": "فرمت ایمیل نامعتبر است",
"members.emailNotSetup": "سرور ایمیل راه‌اندازی نشده است، بنابراین ایمیل‌های دعوت نمی‌توانند ارسال شوند. لطفاً کاربران را از لینک دعوت که پس از دعوت صادر خواهد شد مطلع کنید。",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "آنها می‌توانند پس از ورود به سیستم، مستقیماً به داده‌های تیم شما دسترسی پیدا کنند.",
"members.invitedAsRole": "به عنوان {{role}} دعوت شده",
"members.lastActive": "آخرین فعالیت",
"members.memberActions": "اقدامات عضو",
"members.memberDetails.assign": "تخصیص",
"members.memberDetails.assignedRole": "نقش اختصاص‌یافته",
"members.memberDetails.assignedRoles": "نقش‌های تخصیص‌یافته",
"members.memberDetails.customGroup": "سفارشی‌شده",
"members.memberDetails.generalGroup": "عمومی",
"members.memberDetails.openAria": "باز کردن جزئیات عضو برای {{name}}",
"members.memberDetails.roleActionsAria": "باز کردن اقدامات برای نقش {{role}}",
"members.memberDetails.roleNoPermissionSummary": "نقش فعلی هیچ مجوزی ندارد.",
"members.memberDetails.rolePermissionSummary": "{{role}} می‌تواند <permissionList>{{permissions}}</permissionList>",
"members.memberDetails.title": "جزئیات عضو",
"members.name": "نام",
"members.noNewInvitationsSent": "هیچ دعوت‌نامه جدیدی ارسال نشد",
"members.normalTip": "فقط می‌تواند از برنامه‌ها استفاده کند، نمی‌تواند برنامه بسازد",
"members.ok": "تایید",
"members.pending": "در انتظار...",
"members.removeFromTeam": "حذف از تیم",
"members.removeFromTeamConfirmDescription": "حذف این عضو را تأیید کنید. این عمل قابل بازگشت نیست.",
"members.removeFromTeamConfirmTitle": "حذف {{memberName}} از تیم",
"members.role": "نقش‌ها",
"members.roles": "نقش‌ها",
"members.selectRole": "یک نقش انتخاب کنید",
"members.sendInvite": "ارسال دعوت",
"members.transferModal.codeLabel": "کد تأیید",
"members.transferModal.codePlaceholder": "کد ۶ رقمی را وارد کنید",
@ -425,6 +450,7 @@
"operation.learnMore": "اطلاعات بیشتر",
"operation.log": "گزارش",
"operation.more": "بیشتر",
"operation.moreActions": "اقدامات بیشتر",
"operation.no": "نه",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "هیچ {{content}} یافت نشد",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "ادغام‌ها",
"settings.members": "اعضا",
"settings.permissionSet": "مجموعه مجوزها",
"settings.permissionSetDescription": "مجموعه‌های مجوز را برای استفاده با برنامه‌ها و پایگاه‌های دانش پیکربندی کنید. مجموعه مجوز، مجموعه‌ای قابل استفاده مجدد از مجوزهای عملیات منبع است که می‌توان آن را برای منابع مشخص به اعضا اختصاص داد.",
"settings.preferences": "Preferences",
"settings.provider": "ارائه دهنده مدل",
"settings.resourceAccess": "دسترسی به منابع",
"settings.resourceAccessDescription": "قوانین مجوزی را که برنامه‌ها و پایگاه‌های دانش می‌توانند استفاده کنند پیکربندی کنید. قوانین مجوز می‌توانند در پیکربندی‌های خاص دسترسی به منابع به اعضا تخصیص داده شوند.",
"settings.rolesAndPermissions": "نقش‌ها و مجوزها",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

View File

@ -23,6 +23,7 @@
"form.numberOfKeywords": "تعداد کلمات کلیدی",
"form.onSearchResults": "هیچ عضوی با عبارت جستجوی شما مطابقت ندارد.\nجستجویتان را دوباره امتحان کنید.",
"form.permissions": "مجوزها",
"form.permissionsAccessConfig": "رفتن به پیکربندی دسترسی",
"form.permissionsAllMember": "تمام اعضای تیم",
"form.permissionsInvitedMembers": "برخی از اعضای تیم",
"form.permissionsOnlyMe": "فقط من",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "همه اعضای دارای مجوزهای نقش",
"accessRule.allPermittedMembersDescription": "اعضای دارای مجوزهای نقش منطبق می‌توانند به این منبع دسترسی داشته باشند.",
"accessRule.appDescription": "کنترل کنید این برنامه برای چه کسانی باز است. اعضا همچنان برای مشاهده یا کار با آن به مجوزهای نقش نیاز دارند.",
"accessRule.appTitle": "قوانین دسترسی برنامه",
"accessRule.appTitle": "مجموعه مجوز برنامه",
"accessRule.changeOpenScopeDescription": "تغییر دامنه دسترسی، همه تنظیمات مجوز فردی این منبع را بازنشانی می‌کند. پس از تغییر، باید مجوزهای خاص اعضا را دوباره اضافه کنید.",
"accessRule.changeOpenScopeTitle": "دامنه دسترسی منبع تغییر کند؟",
"accessRule.collapseSection": "جمع کردن {{title}}",
"accessRule.copied": "قانون دسترسی با موفقیت کپی شد",
"accessRule.created": "قانون دسترسی با موفقیت ایجاد شد",
"accessRule.datasetDescription": "کنترل کنید این پایگاه دانش برای چه کسانی باز است. اعضا همچنان برای مشاهده یا کار با آن به مجوزهای نقش نیاز دارند.",
"accessRule.datasetTitle": "قوانین دسترسی پایگاه دانش",
"accessRule.datasetTitle": "مجموعه مجوز پایگاه دانش",
"accessRule.defaultPermission": "بر اساس مجوزهای نقش",
"accessRule.deleteDescription": "این قانون دسترسی به طور دائمی حذف شده و از فهرست مجوزدهی منبع برداشته می‌شود.",
"accessRule.deleteTitle": "حذف «{{name}}»؟",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "Décrivez ce que vous voulez et le formulaire de gauche se remplit au fil de la conversation.",
"agentDetail.configure.build.empty.title": "Créez votre agent par chat",
"agentDetail.configure.build.inputPlaceholder": "Décrivez ce que votre agent doit faire",
"agentDetail.configure.build.startBuild": "Démarrer la création",
"agentDetail.configure.chatFeatures.description": "Façonnez lexpérience de chat de lutilisateur final sur votre webapp et vos surfaces de chat.",
"agentDetail.configure.chatFeatures.title": "Fonctionnalités de chat",
"agentDetail.configure.files.add": "Ajouter un fichier",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} workflow",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} workflows",
"agentDetail.configure.rightPanel.build": "Créer",
"agentDetail.configure.rightPanel.buildTipBody": "Décrivez votre objectif, demandez à votre agent dinstaller et dauthentifier des outils, ou guidez-le avec un cas dexemple complet. Une fois enregistrées, vos instructions seront intégrées à lagent.",
"agentDetail.configure.rightPanel.buildTipBody": "Build configure lagent par chat. Décrivez ce que vous voulez et il remplit la configuration à gauche.",
"agentDetail.configure.rightPanel.buildTipTitle": "Construire votre agent par chat",
"agentDetail.configure.rightPanel.learnMore": "En savoir plus",
"agentDetail.configure.rightPanel.modeLabel": "Mode de configuration de lagent",
"agentDetail.configure.rightPanel.preview": "Aperçu",
"agentDetail.configure.rightPanel.previewTipBody": "Testez-le comme un utilisateur. Votre interaction naffectera pas le comportement futur de lagent.",
"agentDetail.configure.rightPanel.previewTipBody": "Preview exécute lagent final comme vos utilisateurs le verront, avec des réponses claires et les fonctions de chat.",
"agentDetail.configure.rightPanel.previewTipTitle": "Prévisualiser votre agent",
"agentDetail.configure.skills.add": "Ajouter une compétence",
"agentDetail.configure.skills.detail.contentRegion": "Contenu des détails de la compétence",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "Connectez et gérez des serveurs MCP pour donner à vos apps accès à des outils et services externes.",
"members.adminTip": "Peut construire des applications & gérer les paramètres de l'équipe",
"members.alreadyInTeam": "Déjà dans léquipe",
"members.alreadyInTeamTip": "Ces utilisateurs ont déjà accès à cet espace de travail.",
"members.assignRoles": "Attribuer des rôles",
"members.assignRolesModal.description": "Sélectionnez les rôles à attribuer à ce membre. Toutes les autorisations des rôles sélectionnés seront combinées.",
"members.assignRolesModal.selectedCount": "{{count}} sélectionné(s)",
"members.assignRolesModal.singleDescription": "Sélectionnez un rôle à attribuer à ce membre.",
"members.assignRolesModal.title": "Attribuer des rôles",
"members.datasetOperatorTip": "Seul peut gérer la base de connaissances",
"members.editRole": "Modifier le rôle",
"members.editorTip": "Peut créer et modifier des apps",
"members.email": "Courrier électronique",
"members.emailInvalid": "Format de courriel invalide",
"members.emailNotSetup": "Le serveur de messagerie n'est pas configuré, les e-mails d'invitation ne peuvent donc pas être envoyés. Veuillez informer les utilisateurs du lien d'invitation qui sera émis après l'invitation.",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "Ils peuvent accéder directement à vos données d'équipe après s'être connectés.",
"members.invitedAsRole": "Invité en tant que {{role}}",
"members.lastActive": "DERNIÈRE ACTIVITÉ",
"members.memberActions": "Actions du membre",
"members.memberDetails.assign": "Attribuer",
"members.memberDetails.assignedRole": "Rôle attribué",
"members.memberDetails.assignedRoles": "Rôles attribués",
"members.memberDetails.customGroup": "PERSONNALISÉ",
"members.memberDetails.generalGroup": "GÉNÉRAL",
"members.memberDetails.openAria": "Ouvrir les détails du membre pour {{name}}",
"members.memberDetails.roleActionsAria": "Ouvrir les actions pour le rôle {{role}}",
"members.memberDetails.roleNoPermissionSummary": "Le rôle actuel n'a aucune autorisation.",
"members.memberDetails.rolePermissionSummary": "{{role}} peut <permissionList>{{permissions}}</permissionList>",
"members.memberDetails.title": "Détails du membre",
"members.name": "NOM",
"members.noNewInvitationsSent": "Aucune nouvelle invitation envoyée",
"members.normalTip": "Peut seulement utiliser des applications, ne peut pas construire des applications",
"members.ok": "D'accord",
"members.pending": "En attente...",
"members.removeFromTeam": "Retirer de l'équipe",
"members.removeFromTeamConfirmDescription": "Confirmez le retrait de ce membre. Cette action est irréversible.",
"members.removeFromTeamConfirmTitle": "Retirer {{memberName}} de l'équipe",
"members.role": "RÔLES",
"members.roles": "RÔLES",
"members.selectRole": "Sélectionner un rôle",
"members.sendInvite": "Envoyer une invitation",
"members.transferModal.codeLabel": "Code de vérification",
"members.transferModal.codePlaceholder": "Collez le code à 6 chiffres",
@ -425,6 +450,7 @@
"operation.learnMore": "En savoir plus",
"operation.log": "Journal",
"operation.more": "Plus",
"operation.moreActions": "Plus d'actions",
"operation.no": "Non",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Aucun {{content}} n'a été trouvé",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "Intégrations",
"settings.members": "Membres",
"settings.permissionSet": "Ensemble dautorisations",
"settings.permissionSetDescription": "Configurez des ensembles dautorisations à utiliser avec les apps et les bases de connaissances. Un ensemble dautorisations est une collection réutilisable dautorisations dopérations sur les ressources pouvant être attribuée aux membres pour des ressources spécifiques.",
"settings.preferences": "Preferences",
"settings.provider": "Fournisseur de Modèle",
"settings.resourceAccess": "Accès aux ressources",
"settings.resourceAccessDescription": "Configurez les règles d'autorisation que les applications et les bases de connaissances peuvent utiliser. Les règles d'autorisation peuvent être attribuées aux membres dans des configurations d'accès aux ressources spécifiques.",
"settings.rolesAndPermissions": "Rôles et autorisations",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

View File

@ -23,6 +23,7 @@
"form.numberOfKeywords": "Nombre de mots-clés",
"form.onSearchResults": "Aucun membre ne correspond à votre recherche.\nRéessayez votre recherche.",
"form.permissions": "Autorisations",
"form.permissionsAccessConfig": "Accéder à la configuration d'accès",
"form.permissionsAllMember": "Tous les membres de l'équipe",
"form.permissionsInvitedMembers": "Membres partiels de léquipe",
"form.permissionsOnlyMe": "Seulement moi",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "Tous les membres ayant les autorisations de rôle",
"accessRule.allPermittedMembersDescription": "Les membres disposant d'autorisations de rôle correspondantes peuvent accéder à cette ressource.",
"accessRule.appDescription": "Contrôlez à qui cette application est ouverte. Les membres ont toujours besoin d'autorisations de rôle pour la consulter ou l'utiliser.",
"accessRule.appTitle": "Règles d'accès à l'application",
"accessRule.appTitle": "Ensemble d'autorisations d'application",
"accessRule.changeOpenScopeDescription": "Modifier la portée d'ouverture réinitialisera tous les paramètres d'autorisation individuels de cette ressource. Vous devrez ajouter à nouveau les autorisations spécifiques aux membres après le changement.",
"accessRule.changeOpenScopeTitle": "Modifier la portée d'ouverture de la ressource ?",
"accessRule.collapseSection": "Réduire {{title}}",
"accessRule.copied": "Règle d'accès copiée avec succès",
"accessRule.created": "Règle d'accès créée avec succès",
"accessRule.datasetDescription": "Contrôlez à qui cette base de connaissances est ouverte. Les membres ont toujours besoin d'autorisations de rôle pour la consulter ou l'utiliser.",
"accessRule.datasetTitle": "Règles d'accès à la base de connaissances",
"accessRule.datasetTitle": "Ensemble d'autorisations de base de connaissances",
"accessRule.defaultPermission": "Selon les autorisations de rôle",
"accessRule.deleteDescription": "Cette règle d'accès sera définitivement supprimée et retirée de la liste d'autorisation de la ressource.",
"accessRule.deleteTitle": "Supprimer \"{{name}}\" ?",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "आप जो चाहते हैं उसका वर्णन करें और बाईं ओर का फ़ॉर्म बातचीत के साथ भरता जाएगा।",
"agentDetail.configure.build.empty.title": "चैट करके अपना एजेंट बनाएं",
"agentDetail.configure.build.inputPlaceholder": "बताएं कि आपका एजेंट क्या करे",
"agentDetail.configure.build.startBuild": "बिल्ड शुरू करें",
"agentDetail.configure.chatFeatures.description": "अपने Web app और चैट सतहों पर अंतिम-उपयोगकर्ता चैट अनुभव को आकार दें।",
"agentDetail.configure.chatFeatures.title": "चैट सुविधाएँ",
"agentDetail.configure.files.add": "फ़ाइल जोड़ें",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} वर्कफ़्लो",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} वर्कफ़्लो",
"agentDetail.configure.rightPanel.build": "बिल्ड",
"agentDetail.configure.rightPanel.buildTipBody": "अपना goal बताएं, agent को tools install और authenticate करने के निर्देश दें, या उसे एक पूरा sample case समझाएं। Save होने के बाद, आपके instructions agent में शामिल हो जाएंगे।",
"agentDetail.configure.rightPanel.buildTipBody": "Build chat के ज़रिए agent सेट करता है। आप जो चाहते हैं उसे बताएं और यह बाईं ओर की setup भर देता है।",
"agentDetail.configure.rightPanel.buildTipTitle": "Chat के ज़रिए अपना agent बनाएं",
"agentDetail.configure.rightPanel.learnMore": "और जानें",
"agentDetail.configure.rightPanel.modeLabel": "एजेंट कॉन्फ़िगरेशन मोड",
"agentDetail.configure.rightPanel.preview": "पूर्वावलोकन",
"agentDetail.configure.rightPanel.previewTipBody": "इसे user की तरह test करें। आपकी interaction आगे agent के behavior को प्रभावित नहीं करेगी।",
"agentDetail.configure.rightPanel.previewTipBody": "Preview तैयार agent को वैसे चलाता है जैसे आपके users उसे देखेंगे, साफ replies और chat features के साथ।",
"agentDetail.configure.rightPanel.previewTipTitle": "अपने agent का preview करें",
"agentDetail.configure.skills.add": "कौशल जोड़ें",
"agentDetail.configure.skills.detail.contentRegion": "कौशल विवरण सामग्री",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "MCP सर्वर कनेक्ट और प्रबंधित करें ताकि आपके ऐप्स बाहरी टूल और सेवाओं तक पहुँच सकें।",
"members.adminTip": "ऐप्स बना सकते हैं और टीम सेटिंग्स का प्रबंधन कर सकते हैं",
"members.alreadyInTeam": "पहले से टीम में हैं",
"members.alreadyInTeamTip": "इन उपयोगकर्ताओं के पास पहले से इस वर्कस्पेस की पहुंच है।",
"members.assignRoles": "भूमिकाएँ असाइन करें",
"members.assignRolesModal.description": "इस सदस्य को असाइन करने के लिए भूमिकाएँ चुनें। चयनित भूमिकाओं की सभी अनुमतियाँ संयुक्त की जाएंगी।",
"members.assignRolesModal.selectedCount": "{{count}} चयनित",
"members.assignRolesModal.singleDescription": "इस सदस्य को असाइन करने के लिए एक भूमिका चुनें।",
"members.assignRolesModal.title": "भूमिकाएँ असाइन करें",
"members.datasetOperatorTip": "केवल नॉलेज बेस प्रबंधित कर सकते हैं",
"members.editRole": "भूमिका संपादित करें",
"members.editorTip": "ऐप्स बना और संपादित कर सकता है",
"members.email": "ईमेल",
"members.emailInvalid": "अवैध ईमेल प्रारूप",
"members.emailNotSetup": "ईमेल सर्वर सेट नहीं है, इसलिए आमंत्रण ईमेल नहीं भेजे जा सकते। कृपया उपयोगकर्ताओं को आमंत्रण के बाद जारी किए जाने वाले आमंत्रण लिंक के बारे में सूचित करें。",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "वे साइन इन करने के बाद सीधे आपकी टीम डेटा तक पहुंच सकते हैं।",
"members.invitedAsRole": "{{role}} के रूप में आमंत्रित किया गया",
"members.lastActive": "अंतिम सक्रियता",
"members.memberActions": "सदस्य कार्रवाइयाँ",
"members.memberDetails.assign": "असाइन करें",
"members.memberDetails.assignedRole": "असाइन की गई भूमिका",
"members.memberDetails.assignedRoles": "असाइन की गई भूमिकाएँ",
"members.memberDetails.customGroup": "अनुकूलित",
"members.memberDetails.generalGroup": "सामान्य",
"members.memberDetails.openAria": "{{name}} के लिए सदस्य विवरण खोलें",
"members.memberDetails.roleActionsAria": "{{role}} भूमिका के लिए कार्रवाइयाँ खोलें",
"members.memberDetails.roleNoPermissionSummary": "वर्तमान भूमिका के पास कोई अनुमति नहीं है।",
"members.memberDetails.rolePermissionSummary": "{{role}} <permissionList>{{permissions}}</permissionList> कर सकता है",
"members.memberDetails.title": "सदस्य विवरण",
"members.name": "नाम",
"members.noNewInvitationsSent": "कोई नया आमंत्रण नहीं भेजा गया",
"members.normalTip": "केवल ऐप्स का उपयोग कर सकते हैं, ऐप्स नहीं बना सकते",
"members.ok": "ठीक है",
"members.pending": "लंबित...",
"members.removeFromTeam": "टीम से हटाएं",
"members.removeFromTeamConfirmDescription": "इस सदस्य को हटाने की पुष्टि करें। यह कार्रवाई वापस नहीं की जा सकती।",
"members.removeFromTeamConfirmTitle": "{{memberName}} को टीम से हटाएं",
"members.role": "भूमिकाएं",
"members.roles": "भूमिकाएं",
"members.selectRole": "एक भूमिका चुनें",
"members.sendInvite": "आमंत्रण भेजें",
"members.transferModal.codeLabel": "पुष्टिकरण कोड",
"members.transferModal.codePlaceholder": "6 अंकों का कोड पेस्ट करें",
@ -425,6 +450,7 @@
"operation.learnMore": "अधिक जानें",
"operation.log": "लॉग",
"operation.more": "अधिक",
"operation.moreActions": "अधिक कार्रवाइयाँ",
"operation.no": "नहीं",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "कोई {{content}} नहीं मिला",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "एकीकरण",
"settings.members": "सदस्य",
"settings.permissionSet": "अनुमति सेट",
"settings.permissionSetDescription": "ऐप्लिकेशन और नॉलेज बेस के साथ उपयोग के लिए अनुमति सेट कॉन्फ़िगर करें। अनुमति सेट संसाधन संचालन अनुमतियों का पुन: उपयोग योग्य संग्रह है, जिसे विशिष्ट संसाधनों के लिए सदस्यों को असाइन किया जा सकता है।",
"settings.preferences": "Preferences",
"settings.provider": "मॉडल प्रदाता",
"settings.resourceAccess": "संसाधन पहुँच",
"settings.resourceAccessDescription": "ऐप्स और ज्ञान आधार उपयोग कर सकने वाले अनुमति नियमों को कॉन्फ़िगर करें। अनुमति नियमों को विशिष्ट संसाधन पहुँच कॉन्फ़िगरेशन में सदस्यों को असाइन किया जा सकता है।",
"settings.rolesAndPermissions": "भूमिकाएँ और अनुमतियाँ",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

View File

@ -23,6 +23,7 @@
"form.numberOfKeywords": "कीवर्ड की संख्या",
"form.onSearchResults": "कोई सदस्य आपकी खोज क्वेरी से मेल नहीं खाता। अपनी खोज को फिर से प्रयास करें।",
"form.permissions": "अनुमतियां",
"form.permissionsAccessConfig": "पहुँच कॉन्फ़िगरेशन पर जाएँ",
"form.permissionsAllMember": "सभी टीम सदस्यों के लिए",
"form.permissionsInvitedMembers": "आंशिक टीम के सदस्य",
"form.permissionsOnlyMe": "मेरे लिए ही",

View File

@ -5,14 +5,14 @@
"accessRule.allPermittedMembers": "भूमिका अनुमतियों वाले सभी सदस्य",
"accessRule.allPermittedMembersDescription": "मिलती-जुलती भूमिका अनुमतियों वाले सदस्य इस संसाधन तक पहुंच सकते हैं।",
"accessRule.appDescription": "नियंत्रित करें कि यह ऐप किसके लिए खुला है। सदस्यों को इसे देखने या संचालित करने के लिए अभी भी भूमिका अनुमतियों की आवश्यकता होती है।",
"accessRule.appTitle": "ऐप एक्सेस नियम",
"accessRule.appTitle": "ऐप अनुमति सेट",
"accessRule.changeOpenScopeDescription": "खुले दायरे को बदलने से इस संसाधन के लिए सभी व्यक्तिगत अनुमति सेटिंग्स रीसेट हो जाएंगी। स्विच करने के बाद आपको सदस्य-विशिष्ट अनुमतियां फिर से जोड़नी होंगी।",
"accessRule.changeOpenScopeTitle": "संसाधन का खुला दायरा बदलें?",
"accessRule.collapseSection": "{{title}} संक्षिप्त करें",
"accessRule.copied": "एक्सेस नियम सफलतापूर्वक कॉपी किया गया",
"accessRule.created": "एक्सेस नियम सफलतापूर्वक बनाया गया",
"accessRule.datasetDescription": "नियंत्रित करें कि यह ज्ञान आधार किसके लिए खुला है। सदस्यों को इसे देखने या संचालित करने के लिए अभी भी भूमिका अनुमतियों की आवश्यकता होती है।",
"accessRule.datasetTitle": "ज्ञान आधार एक्सेस नियम",
"accessRule.datasetTitle": "ज्ञान आधार अनुमति सेट",
"accessRule.defaultPermission": "भूमिका अनुमतियों के अनुसार",
"accessRule.deleteDescription": "यह एक्सेस नियम स्थायी रूप से हटा दिया जाएगा और संसाधन प्राधिकरण सूची से हटा दिया जाएगा।",
"accessRule.deleteTitle": "\"{{name}}\" हटाएं?",

View File

@ -63,6 +63,7 @@
"agentDetail.configure.build.empty.description": "Jelaskan yang Anda inginkan dan formulir di kiri akan terisi seiring percakapan.",
"agentDetail.configure.build.empty.title": "Bangun agen Anda lewat chat",
"agentDetail.configure.build.inputPlaceholder": "Jelaskan apa yang harus dilakukan agen Anda",
"agentDetail.configure.build.startBuild": "Mulai build",
"agentDetail.configure.chatFeatures.description": "Bentuk pengalaman chat pengguna akhir di Web app dan permukaan chat Anda.",
"agentDetail.configure.chatFeatures.title": "Fitur Chat",
"agentDetail.configure.files.add": "Tambahkan file",
@ -158,11 +159,12 @@
"agentDetail.configure.publishImpact.workflowCount_one": "{{count}} alur kerja",
"agentDetail.configure.publishImpact.workflowCount_other": "{{count}} alur kerja",
"agentDetail.configure.rightPanel.build": "Bangun",
"agentDetail.configure.rightPanel.buildTipBody": "Jelaskan tujuan Anda, instruksikan agen untuk menginstal dan mengautentikasi alat, atau pandu melalui contoh kasus lengkap. Setelah disimpan, instruksi Anda akan menyatu ke dalam agen.",
"agentDetail.configure.rightPanel.buildTipBody": "Build menyiapkan agent lewat chat. Jelaskan yang Anda inginkan dan pengaturan di sebelah kiri akan terisi.",
"agentDetail.configure.rightPanel.buildTipTitle": "Bangun agen Anda lewat chat",
"agentDetail.configure.rightPanel.learnMore": "Pelajari selengkapnya",
"agentDetail.configure.rightPanel.modeLabel": "Mode konfigurasi agen",
"agentDetail.configure.rightPanel.preview": "Pratinjau",
"agentDetail.configure.rightPanel.previewTipBody": "Coba sebagai pengguna. Interaksi Anda tidak akan memengaruhi perilaku agen ke depannya.",
"agentDetail.configure.rightPanel.previewTipBody": "Preview menjalankan agent yang sudah selesai seperti yang akan dilihat pengguna, dengan balasan rapi dan fitur chat.",
"agentDetail.configure.rightPanel.previewTipTitle": "Pratinjau agen Anda",
"agentDetail.configure.skills.add": "Tambahkan keterampilan",
"agentDetail.configure.skills.detail.contentRegion": "Konten detail keterampilan",

View File

@ -213,8 +213,17 @@
"mainNav.workspace.sort.lastOpened": "Last opened",
"mainNav.workspace.sort.openMenu": "Sort workspaces",
"mcpPage.description": "Hubungkan dan kelola server MCP agar aplikasi Anda dapat mengakses alat dan layanan eksternal.",
"members.adminTip": "Dapat membangun aplikasi & mengelola pengaturan tim",
"members.alreadyInTeam": "Sudah ada di tim",
"members.alreadyInTeamTip": "Pengguna ini sudah memiliki akses ke ruang kerja ini.",
"members.assignRoles": "Tetapkan Peran",
"members.assignRolesModal.description": "Pilih peran untuk ditetapkan ke anggota ini. Semua izin dari peran yang dipilih akan digabungkan.",
"members.assignRolesModal.selectedCount": "{{count}} dipilih",
"members.assignRolesModal.singleDescription": "Pilih satu peran untuk ditetapkan ke anggota ini.",
"members.assignRolesModal.title": "Tetapkan Peran",
"members.datasetOperatorTip": "Hanya dapat mengelola basis pengetahuan",
"members.editRole": "Edit Peran",
"members.editorTip": "Dapat membuat dan mengedit aplikasi",
"members.email": "Email",
"members.emailInvalid": "Format Email Tidak Valid",
"members.emailNotSetup": "Server email tidak disiapkan, sehingga email undangan tidak dapat dikirim. Harap beri tahu pengguna tentang tautan undangan yang akan dikeluarkan setelah undangan.",
@ -228,12 +237,28 @@
"members.inviteTeamMemberTip": "Mereka dapat mengakses data tim Anda langsung setelah masuk.",
"members.invitedAsRole": "Diundang sebagai {{role}}",
"members.lastActive": "TERAKHIR AKTIF",
"members.memberActions": "Tindakan anggota",
"members.memberDetails.assign": "Tetapkan",
"members.memberDetails.assignedRole": "Peran yang ditetapkan",
"members.memberDetails.assignedRoles": "Peran yang Ditetapkan",
"members.memberDetails.customGroup": "DISESUAIKAN",
"members.memberDetails.generalGroup": "UMUM",
"members.memberDetails.openAria": "Buka detail anggota untuk {{name}}",
"members.memberDetails.roleActionsAria": "Buka tindakan untuk peran {{role}}",
"members.memberDetails.roleNoPermissionSummary": "Peran saat ini tidak memiliki izin.",
"members.memberDetails.rolePermissionSummary": "{{role}} dapat <permissionList>{{permissions}}</permissionList>",
"members.memberDetails.title": "Detail Anggota",
"members.name": "NAMA",
"members.noNewInvitationsSent": "Tidak ada undangan baru yang dikirim",
"members.normalTip": "Hanya dapat menggunakan aplikasi, tidak dapat membuat aplikasi",
"members.ok": "OKE",
"members.pending": "Tertunda...",
"members.removeFromTeam": "Hapus dari tim",
"members.removeFromTeamConfirmDescription": "Konfirmasi untuk menghapus anggota ini. Tindakan ini tidak dapat dibatalkan.",
"members.removeFromTeamConfirmTitle": "Hapus {{memberName}} dari tim",
"members.role": "PERAN",
"members.roles": "PERAN",
"members.selectRole": "Pilih peran",
"members.sendInvite": "Kirim Undangan",
"members.transferModal.codeLabel": "Kode verifikasi",
"members.transferModal.codePlaceholder": "Tempel kode 6 digit",
@ -425,6 +450,7 @@
"operation.learnMore": "Pelajari lebih lanjut",
"operation.log": "Batang",
"operation.more": "Lebih",
"operation.moreActions": "Tindakan lainnya",
"operation.no": "Tidak",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Tidak ada {{content}} yang ditemukan",
@ -495,8 +521,13 @@
"settings.extension": "Extension",
"settings.integrations": "Integrasi",
"settings.members": "Anggota",
"settings.permissionSet": "Kumpulan Izin",
"settings.permissionSetDescription": "Konfigurasikan kumpulan izin untuk digunakan dengan aplikasi dan basis pengetahuan. Kumpulan izin adalah koleksi izin operasi sumber daya yang dapat digunakan kembali dan dapat ditetapkan kepada anggota untuk sumber daya tertentu.",
"settings.preferences": "Preferences",
"settings.provider": "Penyedia Model",
"settings.resourceAccess": "Akses Sumber Daya",
"settings.resourceAccessDescription": "Konfigurasikan aturan izin yang dapat digunakan oleh aplikasi dan basis pengetahuan. Aturan izin dapat ditetapkan ke anggota dalam konfigurasi akses sumber daya tertentu.",
"settings.rolesAndPermissions": "Peran & Izin",
"settings.settings": "Settings",
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",

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