mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 14:51:13 +08:00
Merge remote-tracking branch 'origin/feat/agent-v2' into feat/agent-v2
This commit is contained in:
commit
c7bfeeaa80
@ -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).
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
1
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
@ -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 |
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ -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')}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -170,6 +170,7 @@ const SnippetCard = ({
|
||||
value={snippet.tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onTagsChange}
|
||||
canBindOrUnbindTags={canManageSnippet}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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": "محتوى تفاصيل المهارة",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"form.numberOfKeywords": "عدد الكلمات الرئيسية",
|
||||
"form.onSearchResults": "لا يوجد أعضاء يطابقون استعلام البحث الخاص بك.\nحاول البحث مرة أخرى.",
|
||||
"form.permissions": "أذونات",
|
||||
"form.permissionsAccessConfig": "الانتقال إلى تكوين الوصول",
|
||||
"form.permissionsAllMember": "جميع أعضاء الفريق",
|
||||
"form.permissionsInvitedMembers": "أعضاء الفريق الجزئيين",
|
||||
"form.permissionsOnlyMe": "أنا فقط",
|
||||
|
||||
@ -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}}\"؟",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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?",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}}\"?",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}}\"?",
|
||||
|
||||
@ -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": "محتوای جزئیات مهارت",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"form.numberOfKeywords": "تعداد کلمات کلیدی",
|
||||
"form.onSearchResults": "هیچ عضوی با عبارت جستجوی شما مطابقت ندارد.\nجستجویتان را دوباره امتحان کنید.",
|
||||
"form.permissions": "مجوزها",
|
||||
"form.permissionsAccessConfig": "رفتن به پیکربندی دسترسی",
|
||||
"form.permissionsAllMember": "تمام اعضای تیم",
|
||||
"form.permissionsInvitedMembers": "برخی از اعضای تیم",
|
||||
"form.permissionsOnlyMe": "فقط من",
|
||||
|
||||
@ -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}}»؟",
|
||||
|
||||
@ -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 l’expérience de chat de l’utilisateur 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 d’installer et d’authentifier des outils, ou guidez-le avec un cas d’exemple complet. Une fois enregistrées, vos instructions seront intégrées à l’agent.",
|
||||
"agentDetail.configure.rightPanel.buildTipBody": "Build configure l’agent 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 l’agent",
|
||||
"agentDetail.configure.rightPanel.preview": "Aperçu",
|
||||
"agentDetail.configure.rightPanel.previewTipBody": "Testez-le comme un utilisateur. Votre interaction n’affectera pas le comportement futur de l’agent.",
|
||||
"agentDetail.configure.rightPanel.previewTipBody": "Preview exécute l’agent 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",
|
||||
|
||||
@ -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 d’autorisations",
|
||||
"settings.permissionSetDescription": "Configurez des ensembles d’autorisations à utiliser avec les apps et les bases de connaissances. Un ensemble d’autorisations est une collection réutilisable d’autorisations d’opé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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}}\" ?",
|
||||
|
||||
@ -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": "कौशल विवरण सामग्री",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"form.numberOfKeywords": "कीवर्ड की संख्या",
|
||||
"form.onSearchResults": "कोई सदस्य आपकी खोज क्वेरी से मेल नहीं खाता। अपनी खोज को फिर से प्रयास करें।",
|
||||
"form.permissions": "अनुमतियां",
|
||||
"form.permissionsAccessConfig": "पहुँच कॉन्फ़िगरेशन पर जाएँ",
|
||||
"form.permissionsAllMember": "सभी टीम सदस्यों के लिए",
|
||||
"form.permissionsInvitedMembers": "आंशिक टीम के सदस्य",
|
||||
"form.permissionsOnlyMe": "मेरे लिए ही",
|
||||
|
||||
@ -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}}\" हटाएं?",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user