From 26b0ff2a0138b0d8947cde8c148434d86e052903 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 23 Jun 2026 20:28:29 +0800 Subject: [PATCH 1/9] feat(agent): copy roster agent into workflow inline agent (#37813) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/composer.py | 36 +- api/openapi/markdown/console-openapi.md | 33 ++ api/services/agent/composer_service.py | 217 ++++++++- api/services/agent/errors.py | 4 + api/services/entities/agent_entities.py | 11 + .../console/agent/test_agent_controllers.py | 53 ++ .../agent/test_agent_composer_entities.py | 22 + .../services/agent/test_agent_services.py | 461 +++++++++++++++++- .../generated/api/console/agent/types.gen.ts | 9 +- .../generated/api/console/agent/zod.gen.ts | 19 +- .../generated/api/console/apps/orpc.gen.ts | 74 ++- .../generated/api/console/apps/types.gen.ts | 30 ++ .../generated/api/console/apps/zod.gen.ts | 35 ++ 13 files changed, 959 insertions(+), 45 deletions(-) diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 975586c635c..b54cf4b6daf 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -28,9 +28,9 @@ from libs.login import login_required from models.model import App, AppMode from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.entities.agent_entities import ComposerSavePayload +from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload -register_schema_models(console_ns, ComposerSavePayload) +register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload) register_response_schema_models( console_ns, AgentAppComposerResponse, @@ -91,6 +91,38 @@ class WorkflowAgentComposerApi(Resource): ) +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/copy-from-roster") +class WorkflowAgentComposerCopyFromRosterApi(Resource): + @console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__]) + @console_ns.response( + 200, + "Workflow roster agent copied to inline agent", + console_ns.models[WorkflowAgentComposerResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + @with_current_user_id + @with_current_tenant_id + def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str): + payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {}) + return dump_response( + WorkflowAgentComposerResponse, + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id=tenant_id, + app_id=app_model.id, + node_id=node_id, + account_id=account_id, + source_agent_id=payload.source_agent_id, + source_snapshot_id=payload.source_snapshot_id, + idempotency_key=payload.idempotency_key, + ), + ) + + @console_ns.route("/apps//workflows/draft/nodes//agent-composer/validate") class WorkflowAgentComposerValidateApi(Resource): @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index cff0286ad8f..6041662f303 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -3807,6 +3807,26 @@ Submit human input form preview for workflow | ---- | ----------- | ------ | | 200 | Workflow agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowComposerCopyFromRosterPayload](#workflowcomposercopyfromrosterpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow roster agent copied to inline agent | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + ### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact #### Parameters @@ -14385,9 +14405,14 @@ Button styles for user actions. | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No | | binding | [ComposerBindingPayload](#composerbindingpayload) | | No | | client_revision_id | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | [AgentIconType](#agenticontype) | | No | | idempotency_key | string | | No | | new_agent_name | string | | No | | node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | No | +| role | string | | No | | save_strategy | [ComposerSaveStrategy](#composersavestrategy) | | Yes | | soul_lock | [ComposerSoulLockPayload](#composersoullockpayload) | | No | | variant | [ComposerVariant](#composervariant) | | Yes | @@ -20560,6 +20585,14 @@ How a workflow node is bound to an Agent. | position_x | number | Comment X position | No | | position_y | number | Comment Y position | No | +#### WorkflowComposerCopyFromRosterPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| idempotency_key | string | | No | +| source_agent_id | string | | Yes | +| source_snapshot_id | string | | No | + #### WorkflowConversationVariableResponse | Name | Type | Description | Required | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 0a17c06300f..230475d5b2a 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -4,6 +4,7 @@ from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql.elements import ColumnElement from extensions.ext_database import db from libs.helper import to_timestamp @@ -13,6 +14,7 @@ from models.agent import ( AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDriveFile, + AgentIconType, AgentKind, AgentScope, AgentSource, @@ -20,9 +22,7 @@ from models.agent import ( WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import ( - DeclaredOutputConfig, -) +from models.agent_config_entities import DeclaredOutputConfig from models.agent_config_entities import ( effective_declared_outputs as _effective_declared_outputs, ) @@ -32,7 +32,9 @@ from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import ( AgentNameConflictError, AgentNotFoundError, + AgentVersionConflictError, AgentVersionNotFoundError, + InvalidComposerConfigError, ) from services.entities.agent_entities import ( AgentSoulConfig, @@ -172,6 +174,86 @@ class AgentComposerService: ) return state + @classmethod + def copy_workflow_composer_from_roster( + cls, + *, + tenant_id: str, + app_id: str, + node_id: str, + account_id: str, + source_agent_id: str, + source_snapshot_id: str | None = None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + binding = cls._require_binding( + cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + ) + + if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and idempotency_key: + agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id if agent else None, + version_id=binding.current_snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + + if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT: + raise InvalidComposerConfigError("Workflow agent node must be bound to a roster agent.") + if binding.agent_id != source_agent_id: + raise InvalidComposerConfigError("Source agent does not match the current workflow node binding.") + + source_agent = cls._require_agent(tenant_id=tenant_id, agent_id=source_agent_id) + if source_agent.scope != AgentScope.ROSTER or source_agent.status != AgentStatus.ACTIVE: + raise InvalidComposerConfigError("Source agent must be an active roster agent.") + source_version = cls._require_version( + tenant_id=tenant_id, + agent_id=source_agent.id, + version_id=source_agent.active_config_snapshot_id, + ) + if source_snapshot_id and source_snapshot_id != source_version.id: + raise AgentVersionConflictError() + + agent_soul = AgentSoulConfig.model_validate(source_version.config_snapshot_dict) + inline_agent = cls._create_workflow_only_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow.id, + node_id=node_id, + account_id=account_id, + agent_soul=agent_soul, + name=source_agent.name, + description=source_agent.description, + role=source_agent.role, + icon_type=source_agent.icon_type, + icon=source_agent.icon, + icon_background=source_agent.icon_background, + ) + cls._copy_agent_drive_rows( + tenant_id=tenant_id, + source_agent_id=source_agent.id, + target_agent_id=inline_agent.id, + account_id=account_id, + agent_soul=agent_soul, + node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), + ) + + binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT + binding.agent_id = inline_agent.id + binding.current_snapshot_id = inline_agent.active_config_snapshot_id + binding.updated_by = account_id + db.session.flush() + db.session.commit() + + version = cls._require_version( + tenant_id=tenant_id, + agent_id=inline_agent.id, + version_id=inline_agent.active_config_snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=inline_agent, version=version) + @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: agent = db.session.scalar( @@ -849,6 +931,11 @@ class AgentComposerService: tenant_id=tenant_id, account_id=account_id, name=agent_name, + description=payload.description or "", + role=payload.role or "", + icon_type=payload.icon_type, + icon=payload.icon, + icon_background=payload.icon_background, agent_soul=payload.agent_soul, operation=AgentConfigRevisionOperation.SAVE_NEW_AGENT, version_note=payload.version_note, @@ -894,6 +981,13 @@ class AgentComposerService: tenant_id=tenant_id, account_id=account_id, name=agent_name, + description=payload.description if payload.description is not None else source_agent.description, + role=payload.role if payload.role is not None else source_agent.role, + icon_type=payload.icon_type if payload.icon_type is not None else source_agent.icon_type, + icon=payload.icon if payload.icon is not None else source_agent.icon, + icon_background=payload.icon_background + if payload.icon_background is not None + else source_agent.icon_background, agent_soul=agent_soul, operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, version_note=payload.version_note, @@ -916,11 +1010,21 @@ class AgentComposerService: node_id: str, account_id: str, agent_soul: AgentSoulConfig, + name: str | None = None, + description: str = "", + role: str = "", + icon_type: Any | None = None, + icon: str | None = None, + icon_background: str | None = None, ) -> Agent: agent = Agent( tenant_id=tenant_id, - name=f"Workflow Agent {node_id}", - description="", + name=name or f"Workflow Agent {node_id}", + description=description, + role=role, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, @@ -945,6 +1049,98 @@ class AgentComposerService: agent.active_config_has_model = agent_soul_has_model(agent_soul) return agent + @classmethod + def _copy_agent_drive_rows( + cls, + *, + tenant_id: str, + source_agent_id: str, + target_agent_id: str, + account_id: str, + agent_soul: AgentSoulConfig, + node_job: WorkflowNodeJobConfig | None = None, + ) -> None: + exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job) + predicates: list[ColumnElement[bool]] = [] + if exact_keys: + predicates.append(AgentDriveFile.key.in_(sorted(exact_keys))) + predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes)) + if not predicates: + return + + source_rows = list( + db.session.scalars( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == source_agent_id, + or_(*predicates), + ) + ).all() + ) + if not source_rows: + return + + existing_target_keys = set( + db.session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == target_agent_id, + AgentDriveFile.key.in_([row.key for row in source_rows]), + ) + ).all() + ) + for row in source_rows: + if row.key in existing_target_keys: + continue + db.session.add( + AgentDriveFile( + tenant_id=tenant_id, + agent_id=target_agent_id, + key=row.key, + file_kind=row.file_kind, + file_id=row.file_id, + value_owned_by_drive=row.value_owned_by_drive, + is_skill=row.is_skill, + skill_metadata=row.skill_metadata, + size=row.size, + hash=row.hash, + mime_type=row.mime_type, + created_by=account_id, + ) + ) + + @staticmethod + def _drive_copy_scopes_from_agent_configs( + *, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None + ) -> tuple[set[str], set[str]]: + from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions + from services.agent_drive_service import decode_drive_mention_ref + + exact_keys: set[str] = set() + prefixes: set[str] = set() + + for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt): + if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: + continue + drive_key = decode_drive_mention_ref(mention.ref_id) + if not drive_key: + continue + if mention.kind == MentionKind.SKILL and "/" in drive_key: + prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/") + else: + exact_keys.add(drive_key) + + if node_job is not None: + for file_ref in node_job.metadata.file_refs or []: + if file_ref.drive_key: + exact_keys.add(file_ref.drive_key) + for output in node_job.declared_outputs: + benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None + if benchmark_ref and benchmark_ref.drive_key: + exact_keys.add(benchmark_ref.drive_key) + + return exact_keys, prefixes + @classmethod def _create_roster_agent_for_composer( cls, @@ -955,11 +1151,20 @@ class AgentComposerService: agent_soul: AgentSoulConfig, operation: AgentConfigRevisionOperation, version_note: str | None, + description: str = "", + role: str = "", + icon_type: AgentIconType | None = None, + icon: str | None = None, + icon_background: str | None = None, ) -> Agent: agent = Agent( tenant_id=tenant_id, name=name, - description="", + description=description, + role=role, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.WORKFLOW, diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py index dcc8f69961f..6a1dc6fb628 100644 --- a/api/services/agent/errors.py +++ b/api/services/agent/errors.py @@ -17,6 +17,10 @@ class AgentArchivedError(Conflict): description = "Archived agent cannot be modified." +class AgentVersionConflictError(Conflict): + description = "Agent config version changed. Please reload and try again." + + class AgentSoulLockedError(BadRequest): description = "Agent Soul is locked for this workflow node." diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py index e7b5cbd7c6d..a8634bceb09 100644 --- a/api/services/entities/agent_entities.py +++ b/api/services/entities/agent_entities.py @@ -42,6 +42,11 @@ class ComposerSavePayload(BaseModel): idempotency_key: str | None = None client_revision_id: str | None = None new_agent_name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + role: str | None = Field(default=None, max_length=255) + icon_type: AgentIconType | None = None + icon: str | None = Field(default=None, max_length=255) + icon_background: str | None = Field(default=None, max_length=255) @model_validator(mode="after") def validate_variant_sections(self) -> "ComposerSavePayload": @@ -58,6 +63,12 @@ class ComposerSavePayload(BaseModel): return self +class WorkflowComposerCopyFromRosterPayload(BaseModel): + source_agent_id: str = Field(min_length=1, max_length=255) + source_snapshot_id: str | None = Field(default=None, max_length=255) + idempotency_key: str | None = Field(default=None, max_length=255) + + class RosterAgentCreatePayload(BaseModel): name: str = Field(min_length=1, max_length=255) mode: Literal["agent"] = "agent" diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 27bb75e21f8..3d84f899379 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -15,6 +15,7 @@ from controllers.console.agent.composer import ( AgentComposerValidateApi, WorkflowAgentComposerApi, WorkflowAgentComposerCandidatesApi, + WorkflowAgentComposerCopyFromRosterApi, WorkflowAgentComposerImpactApi, WorkflowAgentComposerSaveToRosterApi, WorkflowAgentComposerValidateApi, @@ -1017,6 +1018,58 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( )["save_options"] == ["node_job_only"] +def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None: + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + def fake_copy_from_roster(**kwargs): + captured.update(kwargs) + return _workflow_composer_response( + binding={ + "id": "binding-1", + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-version-1", + "workflow_id": "workflow-1", + "node_id": kwargs["node_id"], + }, + agent={ + "id": "inline-agent-1", + "name": "Nadia", + "description": "", + "scope": "workflow_only", + "status": "active", + }, + active_config_snapshot={"id": "inline-version-1", "version": 1}, + ) + + monkeypatch.setattr( + composer_controller.AgentComposerService, "copy_workflow_composer_from_roster", fake_copy_from_roster + ) + + with app.test_request_context( + json={ + "source_agent_id": "roster-agent-1", + "source_snapshot_id": "roster-version-1", + "idempotency_key": "copy-1", + } + ): + result = unwrap(WorkflowAgentComposerCopyFromRosterApi.post)( + WorkflowAgentComposerCopyFromRosterApi(), "tenant-1", account_id, app_model, "node-1" + ) + + assert result["binding"]["binding_type"] == "inline_agent" + assert captured == { + "tenant_id": "tenant-1", + "app_id": "app-1", + "node_id": "node-1", + "account_id": account_id, + "source_agent_id": "roster-agent-1", + "source_snapshot_id": "roster-version-1", + "idempotency_key": "copy-1", + } + + def test_workflow_impact_returns_empty_without_version(app: Flask) -> None: payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value} diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index e82ba92029b..23988c2ec20 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -105,6 +105,28 @@ def test_agent_app_soul_allows_app_features_and_variables(): assert payload.agent_soul.app_variables[0].name == "company_name" +def test_composer_save_payload_accepts_new_roster_metadata(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.SAVE_TO_ROSTER, + "new_agent_name": "Research Agent", + "description": "Finds relevant sources.", + "role": "Research Assistant", + "icon_type": "emoji", + "icon": "search", + "icon_background": "#E0F2FE", + } + ) + + assert payload.new_agent_name == "Research Agent" + assert payload.description == "Finds relevant sources." + assert payload.role == "Research Assistant" + assert payload.icon_type == "emoji" + assert payload.icon == "search" + assert payload.icon_background == "#E0F2FE" + + def test_knowledge_query_mode_uses_stable_backend_enums(): config = AgentSoulConfig.model_validate( { diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 9ba62d60375..1d0d5ed42c6 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -10,6 +10,7 @@ from models.agent import ( AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDebugConversation, + AgentDriveFile, AgentKind, AgentScope, AgentSource, @@ -31,7 +32,7 @@ from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import InvalidComposerConfigError +from services.agent.errors import AgentVersionConflictError, InvalidComposerConfigError from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.app_service import AppListParams, AppService @@ -415,9 +416,28 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) workflow_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") - roster_agent = SimpleNamespace(id="roster-agent-1", active_config_snapshot_id="roster-version-1", name="Roster") + roster_agent = SimpleNamespace( + id="roster-agent-1", + active_config_snapshot_id="roster-version-1", + name="Roster", + description="Source description", + role="Source role", + icon_type="emoji", + icon="source", + icon_background="#FFFFFF", + ) + create_roster_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) - monkeypatch.setattr(AgentComposerService, "_create_roster_agent_for_composer", lambda **kwargs: roster_agent) + + def fake_create_roster_agent_for_composer(**kwargs): + create_roster_calls.append(kwargs) + return roster_agent + + monkeypatch.setattr( + AgentComposerService, + "_create_roster_agent_for_composer", + fake_create_roster_agent_for_composer, + ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, @@ -443,6 +463,11 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk "agent_soul": {"prompt": {"system_prompt": "new"}}, "node_job": {"workflow_prompt": "use prior output"}, "new_agent_name": "Copied Agent", + "description": "Copied description", + "role": "Copied role", + "icon_type": "emoji", + "icon": "copied", + "icon_background": "#E0F2FE", } ) existing_binding = WorkflowAgentNodeBinding(agent_id="inline-agent-1", current_snapshot_id="inline-version-1") @@ -500,6 +525,14 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk assert new_agent_binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert save_to_roster_binding.agent_id == "roster-agent-1" assert new_version_binding.current_snapshot_id == "new-version-1" + assert create_roster_calls[0]["description"] == "Copied description" + assert create_roster_calls[0]["role"] == "Copied role" + assert create_roster_calls[0]["icon"] == "copied" + assert create_roster_calls[0]["icon_background"] == "#E0F2FE" + assert create_roster_calls[1]["description"] == "Copied description" + assert create_roster_calls[1]["role"] == "Copied role" + assert create_roster_calls[1]["icon"] == "copied" + assert create_roster_calls[1]["icon_background"] == "#E0F2FE" def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch): @@ -715,6 +748,428 @@ def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypat ) +def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_node_job( + monkeypatch: pytest.MonkeyPatch, +): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + workflow = SimpleNamespace(id="workflow-1") + node_job = WorkflowNodeJobConfig(workflow_prompt="keep this node task") + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="old-roster-version", + node_job_config=node_job, + ) + roster_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Nadia", + description="Clarification Drafter", + role="Clarifies tenders", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="roster-version-2", + ) + source_version = AgentConfigSnapshot( + id="roster-version-2", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=2, + config_snapshot='{"prompt":{"system_prompt":"copy me"}}', + ) + inline_agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Nadia", + description="Clarification Drafter", + role="Clarifies tenders", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-1", + ) + captured: dict[str, object] = {} + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: workflow) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) + + def fake_create_workflow_only_agent(**kwargs): + captured["create"] = kwargs + return inline_agent + + def fake_copy_drive_rows(**kwargs): + captured["drive"] = kwargs + + monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) + monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: { + "binding": { + "binding_type": kwargs["binding"].binding_type.value, + "agent_id": kwargs["binding"].agent_id, + "current_snapshot_id": kwargs["binding"].current_snapshot_id, + }, + "node_job": kwargs["binding"].node_job_config_dict, + }, + ) + + state = AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + source_snapshot_id="roster-version-2", + ) + + assert state["binding"]["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value + assert state["binding"]["agent_id"] == "inline-agent-1" + assert state["node_job"]["workflow_prompt"] == "keep this node task" + assert binding.node_job_config is node_job + create_kwargs = captured["create"] + assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me" + assert create_kwargs["name"] == "Nadia" + assert create_kwargs["role"] == "Clarifies tenders" + drive_kwargs = captured["drive"] + assert drive_kwargs["source_agent_id"] == "roster-agent-1" + assert drive_kwargs["target_agent_id"] == "inline-agent-1" + assert fake_session.commits == 1 + + +def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + lambda **kwargs: WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=WorkflowNodeJobConfig(), + ), + ) + roster_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Nadia", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="roster-version-2", + ) + source_version = AgentConfigSnapshot( + id="roster-version-2", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=2, + config_snapshot='{"prompt":{"system_prompt":"copy me"}}', + ) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) + + with pytest.raises(AgentVersionConflictError): + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + source_snapshot_id="roster-version-1", + ) + + +def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(monkeypatch: pytest.MonkeyPatch): + inline_binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + inline_agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Inline", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-1", + ) + inline_version = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"inline"}}', + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding) + monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: {"binding_type": kwargs["binding"].binding_type.value}, + ) + + state = AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + idempotency_key="same-click", + ) + + assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value} + + +@pytest.mark.parametrize( + ("binding_agent_id", "binding_type", "source_scope", "source_status", "expected_message"), + [ + ( + "roster-agent-1", + WorkflowAgentBindingType.INLINE_AGENT, + AgentScope.ROSTER, + AgentStatus.ACTIVE, + "must be bound to a roster agent", + ), + ( + "other-agent", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.ROSTER, + AgentStatus.ACTIVE, + "does not match", + ), + ( + "roster-agent-1", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.WORKFLOW_ONLY, + AgentStatus.ACTIVE, + "must be an active roster agent", + ), + ( + "roster-agent-1", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.ROSTER, + AgentStatus.ARCHIVED, + "must be an active roster agent", + ), + ], +) +def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding( + monkeypatch: pytest.MonkeyPatch, + binding_agent_id: str, + binding_type: WorkflowAgentBindingType, + source_scope: AgentScope, + source_status: AgentStatus, + expected_message: str, +): + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=binding_type, + agent_id=binding_agent_id, + current_snapshot_id="version-1", + node_job_config=WorkflowNodeJobConfig(), + ) + source_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Source", + scope=source_scope, + source=AgentSource.AGENT_APP, + status=source_status, + active_config_snapshot_id="version-1", + ) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: source_agent) + + with pytest.raises(InvalidComposerConfigError, match=expected_message): + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + ) + + +def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch): + skill_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="tender-analyzer/SKILL.md", + file_kind="tool_file", + file_id="tool-file-1", + value_owned_by_drive=True, + is_skill=True, + skill_metadata='{"name":"Tender Analyzer"}', + size=10, + mime_type="text/markdown", + ) + script_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="tender-analyzer/scripts/run.sh", + file_kind="tool_file", + file_id="tool-file-2", + value_owned_by_drive=True, + size=20, + mime_type="text/x-shellscript", + ) + file_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="files/qna.pdf", + file_kind="upload_file", + file_id="upload-file-1", + value_owned_by_drive=False, + size=30, + mime_type="application/pdf", + ) + fake_session = FakeSession(scalars=[[skill_row, script_row, file_row], []]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate( + { + "prompt": { + "system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]", + }, + } + ) + node_job = WorkflowNodeJobConfig.model_validate( + {"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}} + ) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + node_job=node_job, + ) + + copied = [row for row in fake_session.added if isinstance(row, AgentDriveFile)] + assert [row.key for row in copied] == [ + "tender-analyzer/SKILL.md", + "tender-analyzer/scripts/run.sh", + "files/qna.pdf", + ] + assert {row.agent_id for row in copied} == {"inline-agent-1"} + assert copied[0].file_id == "tool-file-1" + assert copied[0].is_skill is True + assert copied[2].value_owned_by_drive is False + + +def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}}) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + ) + + assert fake_session.added == [] + + +def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch): + source_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="files/qna.pdf", + file_kind="upload_file", + file_id="upload-file-1", + value_owned_by_drive=False, + size=30, + mime_type="application/pdf", + ) + fake_session = FakeSession(scalars=[[source_row], ["files/qna.pdf"]]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}}) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + ) + + assert [row for row in fake_session.added if isinstance(row, AgentDriveFile)] == [] + + +def test_drive_copy_scopes_include_declared_output_benchmark_files(): + agent_soul = AgentSoulConfig.model_validate( + { + "prompt": { + "system_prompt": ( + "[§file:files/source.pdf:source.pdf§] " + "[§knowledge:dataset-1:Docs§] " + "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]" + ) + }, + } + ) + node_job = WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + { + "name": "qna_report", + "type": "file", + "check": { + "enabled": True, + "prompt": "Compare the generated file with the benchmark.", + "benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"}, + }, + }, + { + "name": "summary", + "type": "string", + "check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}}, + }, + ], + } + ) + + exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs( + agent_soul=agent_soul, + node_job=node_job, + ) + + assert exact_keys == {"files/source.pdf", "files/expected.pdf"} + assert prefixes == {"tender-analyzer/"} + + def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index aa21f2ce651..43119c4f1f4 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -134,9 +134,14 @@ export type ComposerSavePayload = { agent_soul?: AgentSoulConfig | null binding?: ComposerBindingPayload | null client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null idempotency_key?: string | null new_agent_name?: string | null node_job?: WorkflowNodeJobConfig | null + role?: string | null save_strategy: ComposerSaveStrategy soul_lock?: ComposerSoulLockPayload variant: ComposerVariant @@ -536,6 +541,8 @@ export type ComposerBindingPayload = { current_snapshot_id?: string | null } +export type AgentIconType = 'emoji' | 'image' | 'link' + export type WorkflowNodeJobConfig = { declared_outputs?: Array human_contacts?: Array @@ -876,8 +883,6 @@ export type LlmMode = 'chat' | 'completion' export type AgentKind = 'dify_agent' -export type AgentIconType = 'emoji' | 'image' | 'link' - export type AgentPublishedReferenceResponse = { app_icon?: string | null app_icon_background?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index cb4107f2d53..d7f5681ffc4 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -282,6 +282,13 @@ export const zComposerBindingPayload = z.object({ current_snapshot_id: z.string().nullish(), }) +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + /** * ComposerSoulLockPayload */ @@ -830,13 +837,6 @@ export const zAgentAppDetailWithSite = z.object({ */ export const zAgentKind = z.enum(['dify_agent']) -/** - * AgentIconType - * - * Supported icon storage formats for Agent roster entries. - */ -export const zAgentIconType = z.enum(['emoji', 'image', 'link']) - /** * AgentPublishedReferenceResponse */ @@ -1876,9 +1876,14 @@ export const zComposerSavePayload = z.object({ agent_soul: zAgentSoulConfig.nullish(), binding: zComposerBindingPayload.nullish(), client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), idempotency_key: z.string().nullish(), new_agent_name: z.string().min(1).max(255).nullish(), node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), save_strategy: zComposerSaveStrategy, soul_lock: zComposerSoulLockPayload.optional(), variant: zComposerVariant, diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 7a93572885e..ea72df28458 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -392,6 +392,9 @@ import { zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse, @@ -3479,6 +3482,26 @@ export const candidates = { } export const post51 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRoster', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + }), + ) + .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) + +export const copyFromRoster = { + post: post51, +} + +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3495,10 +3518,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post51, + post: post52, } -export const post52 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3515,10 +3538,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post52, + post: post53, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3535,7 +3558,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post53, + post: post54, } export const get62 = oc @@ -3569,6 +3592,7 @@ export const agentComposer = { get: get62, put: put4, candidates, + copyFromRoster, impact, saveToRoster, validate, @@ -3598,7 +3622,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post54 = oc +export const post55 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3617,7 +3641,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post54, + post: post55, } /** @@ -3625,7 +3649,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post55 = oc +export const post56 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3639,7 +3663,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post55, + post: post56, } export const trigger = { @@ -3699,7 +3723,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post56 = oc +export const post57 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3718,7 +3742,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post56, + post: post57, } /** @@ -3840,7 +3864,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post57 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3859,7 +3883,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post57, + post: post58, } /** @@ -3867,7 +3891,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post58 = oc +export const post59 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -3886,7 +3910,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post58, + post: post59, } export const trigger2 = { @@ -4039,7 +4063,7 @@ export const get72 = oc * * Sync draft workflow configuration */ -export const post59 = oc +export const post60 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4059,7 +4083,7 @@ export const post59 = oc export const draft2 = { get: get72, - post: post59, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4095,7 +4119,7 @@ export const get73 = oc /** * Publish workflow */ -export const post60 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4114,7 +4138,7 @@ export const post60 = oc export const publish = { get: get73, - post: post60, + post: post61, } /** @@ -4251,7 +4275,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post61 = oc +export const post62 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4264,7 +4288,7 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post61, + post: post62, } /** @@ -4489,7 +4513,7 @@ export const get81 = oc * * Create a new API key for an app */ -export const post62 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4505,7 +4529,7 @@ export const post62 = oc export const apiKeys = { get: get81, - post: post62, + post: post63, byApiKeyId, } @@ -4563,7 +4587,7 @@ export const get83 = oc * * Create a new application */ -export const post63 = oc +export const post64 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4579,7 +4603,7 @@ export const post63 = oc export const apps = { get: get83, - post: post63, + post: post64, imports, starred, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index fa56590f0a4..9e79518f3cd 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -986,9 +986,14 @@ export type ComposerSavePayload = { agent_soul?: AgentSoulConfig | null binding?: ComposerBindingPayload | null client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null idempotency_key?: string | null new_agent_name?: string | null node_job?: WorkflowNodeJobConfig | null + role?: string | null save_strategy: ComposerSaveStrategy soul_lock?: ComposerSoulLockPayload variant: ComposerVariant @@ -1003,6 +1008,12 @@ export type AgentComposerCandidatesResponse = { variant: ComposerVariant } +export type WorkflowComposerCopyFromRosterPayload = { + idempotency_key?: string | null + source_agent_id: string + source_snapshot_id?: string | null +} + export type AgentComposerImpactResponse = { bindings?: Array current_snapshot_id?: string | null @@ -1873,6 +1884,8 @@ export type ComposerBindingPayload = { current_snapshot_id?: string | null } +export type AgentIconType = 'emoji' | 'image' | 'link' + export type ComposerSoulLockPayload = { locked?: boolean unlocked_from_version_id?: string | null @@ -5415,6 +5428,23 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResp export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterData = { + body: WorkflowComposerCopyFromRosterPayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses = { + 200: WorkflowAgentComposerResponse +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse + = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses] + export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = { body: ComposerSavePayload path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 043fc11261f..9b86fda0a62 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -642,6 +642,15 @@ export const zHumanInputDeliveryTestPayload = z.object({ */ export const zEmptyObjectResponse = z.record(z.string(), z.unknown()) +/** + * WorkflowComposerCopyFromRosterPayload + */ +export const zWorkflowComposerCopyFromRosterPayload = z.object({ + idempotency_key: z.string().max(255).nullish(), + source_agent_id: z.string().min(1).max(255), + source_snapshot_id: z.string().max(255).nullish(), +}) + /** * DraftWorkflowNodeRunPayload */ @@ -1835,6 +1844,13 @@ export const zComposerBindingPayload = z.object({ current_snapshot_id: z.string().nullish(), }) +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + /** * ComposerSoulLockPayload */ @@ -3336,9 +3352,14 @@ export const zComposerSavePayload = z.object({ agent_soul: zAgentSoulConfig.nullish(), binding: zComposerBindingPayload.nullish(), client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), idempotency_key: z.string().nullish(), new_agent_name: z.string().min(1).max(255).nullish(), node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), save_strategy: zComposerSaveStrategy, soul_lock: zComposerSoulLockPayload.optional(), variant: zComposerVariant, @@ -5342,6 +5363,20 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody + = zWorkflowComposerCopyFromRosterPayload + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath = z.object({ + app_id: z.uuid(), + node_id: z.string(), +}) + +/** + * Workflow roster agent copied to inline agent + */ +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse + = zWorkflowAgentComposerResponse + export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody = zComposerSavePayload From ed500761c836b3fd45899f3d74109e8898055dcc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:41:44 +0000 Subject: [PATCH 2/9] chore(i18n): sync translations with en-US (#37760) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/permission-keys.json | 51 ++++++++++++++ web/i18n/ar-TN/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/de-DE/permission-keys.json | 51 ++++++++++++++ web/i18n/de-DE/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/es-ES/permission-keys.json | 51 ++++++++++++++ web/i18n/es-ES/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/fa-IR/permission-keys.json | 51 ++++++++++++++ web/i18n/fa-IR/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/fr-FR/permission-keys.json | 51 ++++++++++++++ web/i18n/fr-FR/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/hi-IN/permission-keys.json | 51 ++++++++++++++ web/i18n/hi-IN/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/id-ID/permission-keys.json | 51 ++++++++++++++ web/i18n/id-ID/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/it-IT/permission-keys.json | 51 ++++++++++++++ web/i18n/it-IT/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/ko-KR/permission-keys.json | 51 ++++++++++++++ web/i18n/ko-KR/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/nl-NL/permission-keys.json | 51 ++++++++++++++ web/i18n/nl-NL/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/pl-PL/permission-keys.json | 51 ++++++++++++++ web/i18n/pl-PL/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/pt-BR/permission-keys.json | 51 ++++++++++++++ web/i18n/pt-BR/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/ro-RO/permission-keys.json | 51 ++++++++++++++ web/i18n/ro-RO/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/ru-RU/permission-keys.json | 51 ++++++++++++++ web/i18n/ru-RU/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/sl-SI/permission-keys.json | 51 ++++++++++++++ web/i18n/sl-SI/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/th-TH/permission-keys.json | 51 ++++++++++++++ web/i18n/th-TH/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/tr-TR/permission-keys.json | 51 ++++++++++++++ web/i18n/tr-TR/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/uk-UA/permission-keys.json | 51 ++++++++++++++ web/i18n/uk-UA/permission.json | 105 ++++++++++++++++++++++++++++ web/i18n/vi-VN/permission-keys.json | 51 ++++++++++++++ web/i18n/vi-VN/permission.json | 105 ++++++++++++++++++++++++++++ 38 files changed, 2964 insertions(+) create mode 100644 web/i18n/ar-TN/permission-keys.json create mode 100644 web/i18n/ar-TN/permission.json create mode 100644 web/i18n/de-DE/permission-keys.json create mode 100644 web/i18n/de-DE/permission.json create mode 100644 web/i18n/es-ES/permission-keys.json create mode 100644 web/i18n/es-ES/permission.json create mode 100644 web/i18n/fa-IR/permission-keys.json create mode 100644 web/i18n/fa-IR/permission.json create mode 100644 web/i18n/fr-FR/permission-keys.json create mode 100644 web/i18n/fr-FR/permission.json create mode 100644 web/i18n/hi-IN/permission-keys.json create mode 100644 web/i18n/hi-IN/permission.json create mode 100644 web/i18n/id-ID/permission-keys.json create mode 100644 web/i18n/id-ID/permission.json create mode 100644 web/i18n/it-IT/permission-keys.json create mode 100644 web/i18n/it-IT/permission.json create mode 100644 web/i18n/ko-KR/permission-keys.json create mode 100644 web/i18n/ko-KR/permission.json create mode 100644 web/i18n/nl-NL/permission-keys.json create mode 100644 web/i18n/nl-NL/permission.json create mode 100644 web/i18n/pl-PL/permission-keys.json create mode 100644 web/i18n/pl-PL/permission.json create mode 100644 web/i18n/pt-BR/permission-keys.json create mode 100644 web/i18n/pt-BR/permission.json create mode 100644 web/i18n/ro-RO/permission-keys.json create mode 100644 web/i18n/ro-RO/permission.json create mode 100644 web/i18n/ru-RU/permission-keys.json create mode 100644 web/i18n/ru-RU/permission.json create mode 100644 web/i18n/sl-SI/permission-keys.json create mode 100644 web/i18n/sl-SI/permission.json create mode 100644 web/i18n/th-TH/permission-keys.json create mode 100644 web/i18n/th-TH/permission.json create mode 100644 web/i18n/tr-TR/permission-keys.json create mode 100644 web/i18n/tr-TR/permission.json create mode 100644 web/i18n/uk-UA/permission-keys.json create mode 100644 web/i18n/uk-UA/permission.json create mode 100644 web/i18n/vi-VN/permission-keys.json create mode 100644 web/i18n/vi-VN/permission.json diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json new file mode 100644 index 00000000000..cd7c67a28e6 --- /dev/null +++ b/web/i18n/ar-TN/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "إدارة إعدادات امتداد API", + "app.access_config": "تكوين أذونات الوصول إلى التطبيق", + "app.acl.access_config": "تكوين أذونات الوصول إلى التطبيق", + "app.acl.delete": "حذف التطبيق", + "app.acl.edit": "تعديل التطبيق وتنسيقه", + "app.acl.import_export_dsl": "استيراد / تصدير DSL", + "app.acl.monitor": "المراقبة والعمليات", + "app.acl.preview": "معاينة التطبيق", + "app.acl.release_and_version": "نشر التطبيق وإدارة الإصدارات", + "app.acl.test_and_run": "اختبار التطبيق واستخدامه", + "app.acl.view_layout": "صفحة التنسيق للقراءة فقط", + "app.create_and_management": "إنشاء التطبيقات وإدارة التطبيقات التي أنشأتها", + "app.tag.manage": "إدارة وسوم التطبيق", + "app_library.access": "الوصول إلى مكتبة التطبيقات", + "billing.manage": "تغيير خطط الاشتراك", + "billing.subscription.manage": "إدارة الفوترة والاشتراكات في بوابة الفوترة", + "billing.view": "الوصول إلى إعدادات الفوترة", + "credential.create": "إضافة بيانات الاعتماد", + "credential.manage": "تعديل بيانات الاعتماد وحذفها", + "credential.use": "عرض بيانات الاعتماد واستخدامها", + "customization.manage": "إدارة التخصيص", + "data_source.manage": "إدارة تكوين مصدر البيانات", + "dataset.access_config": "تكوين أذونات الوصول إلى قاعدة المعرفة", + "dataset.acl.access_config": "تكوين أذونات الوصول إلى قاعدة المعرفة", + "dataset.acl.delete": "حذف قاعدة المعرفة", + "dataset.acl.delete_file": "حذف ملفات قاعدة المعرفة", + "dataset.acl.document_download": "تنزيل المستندات", + "dataset.acl.edit": "تعديل قاعدة المعرفة", + "dataset.acl.import_export_dsl": "استيراد / تصدير DSL لخط أنابيب المعرفة", + "dataset.acl.pipeline_release": "نشر خط أنابيب المعرفة وإدارة الإصدارات", + "dataset.acl.pipeline_test": "اختبار خط الأنابيب", + "dataset.acl.preview": "معاينة قاعدة المعرفة", + "dataset.acl.readonly": "قاعدة المعرفة للقراءة فقط", + "dataset.acl.retrieval_recall": "استرجاع قاعدة المعرفة", + "dataset.acl.use": "إضافة مستندات إلى قاعدة المعرفة", + "dataset.api_key.manage": "إدارة مفاتيح API لقاعدة المعرفة", + "dataset.create_and_management": "إنشاء قواعد المعرفة وإدارة قواعد المعرفة التي أنشأتها", + "dataset.external.connect": "الاتصال بقواعد المعرفة الخارجية", + "dataset.tag.manage": "إدارة وسوم قاعدة المعرفة", + "mcp.manage": "إدارة MCP", + "plugin.debug": "تصحيح الإضافات", + "plugin.install": "تثبيت الإضافات وتحديثها", + "plugin.manage": "إدارة الإضافات", + "plugin.plugin_preferences": "إدارة تفضيلات الإضافات", + "snippets.create_and_modify": "إنشاء المقتطفات وتعديلها", + "snippets.management": "إدارة المقتطفات", + "tool.manage": "إدارة الأدوات", + "workspace.member.manage": "إدارة الأعضاء", + "workspace.role.manage": "إدارة أذونات الأدوار وقواعد الوصول إلى الموارد" +} diff --git a/web/i18n/ar-TN/permission.json b/web/i18n/ar-TN/permission.json new file mode 100644 index 00000000000..75acc42fb23 --- /dev/null +++ b/web/i18n/ar-TN/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "الإجراءات", + "accessRule.addMemberAria": "إضافة {{name}}", + "accessRule.addMembersTitle": "إضافة أعضاء", + "accessRule.allPermittedMembers": "جميع الأعضاء الذين لديهم أذونات الأدوار", + "accessRule.allPermittedMembersDescription": "يمكن للأعضاء الذين لديهم أذونات أدوار مطابقة الوصول إلى هذا المورد.", + "accessRule.appDescription": "تحكم في من يُفتح له هذا التطبيق. لا يزال الأعضاء بحاجة إلى أذونات الأدوار لعرضه أو تشغيله.", + "accessRule.appTitle": "قواعد الوصول إلى التطبيق", + "accessRule.changeOpenScopeDescription": "سيؤدي تغيير نطاق الفتح إلى إعادة تعيين جميع إعدادات الأذونات الفردية لهذا المورد. ستحتاج إلى إضافة أذونات خاصة بالأعضاء مرة أخرى بعد التبديل.", + "accessRule.changeOpenScopeTitle": "تغيير نطاق فتح المورد؟", + "accessRule.collapseSection": "طي {{title}}", + "accessRule.copied": "تم نسخ قاعدة الوصول بنجاح", + "accessRule.created": "تم إنشاء قاعدة الوصول بنجاح", + "accessRule.datasetDescription": "تحكم في من تُفتح له قاعدة المعرفة هذه. لا يزال الأعضاء بحاجة إلى أذونات الأدوار لعرضها أو تشغيلها.", + "accessRule.datasetTitle": "قواعد الوصول إلى قاعدة المعرفة", + "accessRule.defaultPermission": "حسب أذونات الأدوار", + "accessRule.deleteDescription": "سيتم حذف قاعدة الوصول هذه نهائيًا وإزالتها من قائمة تفويض المورد.", + "accessRule.deleteTitle": "حذف \"{{name}}\"؟", + "accessRule.deleted": "تم حذف قاعدة الوصول بنجاح", + "accessRule.exceptionPermissionFor": "إذن استثنائي لـ {{name}}", + "accessRule.expandSection": "توسيع {{title}}", + "accessRule.individualPermissionSettings": "إعدادات الأذونات الفردية", + "accessRule.individualPermissionSettingsTip": "عيّن استثناءات الأذونات لمتعاونين أو مجموعات محددة. تتجاوز هذه الإعدادات مستوى الوصول الافتراضي.", + "accessRule.lockedSummary_one": "· {{count}} مقفل", + "accessRule.lockedSummary_other": "· {{count}} مقفل", + "accessRule.maintainer": "مشرف الصيانة", + "accessRule.member": "عضو", + "accessRule.newPermissionSet": "مجموعة أذونات جديدة", + "accessRule.noAvailableMembers": "لا يوجد أعضاء متاحون للإضافة", + "accessRule.noDescription": "لا يوجد وصف", + "accessRule.noRoles": "لا توجد أدوار", + "accessRule.noRules": "لا توجد قواعد وصول", + "accessRule.noUserAccessSettings": "لا توجد إعدادات أذونات فردية", + "accessRule.permission": "الإذن", + "accessRule.resourceOpenScope": "نطاق فتح المورد", + "accessRule.resourceOpenScopeDescription": "اختر من يُفتح له هذا المورد. لا تزال أذونات الأدوار تحدد ما يمكن لكل عضو فعله.", + "accessRule.specificMembersOnly": "أعضاء محددون فقط", + "accessRule.specificMembersOnlyDescription": "يمكن للأعضاء المحددين فقط الوصول إلى هذا المورد.", + "accessRule.summary_one": "{{count}} مجموعة أذونات", + "accessRule.summary_other": "{{count}} مجموعات أذونات", + "accessRule.updated": "تم تحديث قاعدة الوصول بنجاح", + "common.duplicateAction": "تكرار", + "group.app": "التطبيقات", + "group.app_acl": "أذونات الوصول إلى التطبيق", + "group.billing": "الفوترة", + "group.credential": "بيانات الاعتماد", + "group.dataset": "قواعد المعرفة", + "group.dataset_acl": "أذونات الوصول إلى قاعدة المعرفة", + "group.integration": "التكاملات", + "group.plugin": "الإضافات", + "group.tool_mcp": "الأدوات و MCP", + "group.workspace": "مساحة العمل", + "permissionList.clearAll": "مسح الكل", + "permissionList.collapseGroup": "طي المجموعة", + "permissionList.expandGroup": "توسيع المجموعة", + "permissionList.noPermissionsFound": "لم يتم العثور على أذونات", + "permissionList.selectAll": "تحديد الكل", + "permissionSet.descriptionLabel": "الوصف", + "permissionSet.descriptionPlaceholder": "صف ما تمنحه مجموعة الأذونات هذه", + "permissionSet.learnMore": "تعرّف على المزيد حول الأذونات", + "permissionSet.modal.create.app.description": "أنشئ مجموعة أذونات تطبيق يمكن الرجوع إليها في قواعد الوصول للتفويض السريع.", + "permissionSet.modal.create.app.title": "إنشاء مجموعة أذونات التطبيق", + "permissionSet.modal.create.dataset.description": "أنشئ مجموعة أذونات قاعدة معرفة يمكن الرجوع إليها في قواعد الوصول للتفويض السريع.", + "permissionSet.modal.create.dataset.title": "إنشاء مجموعة أذونات قاعدة المعرفة", + "permissionSet.modal.edit.app.description": "عدّل الاسم والوصف والأذونات الممنوحة لمجموعة الأذونات هذه.", + "permissionSet.modal.edit.app.title": "تعديل مجموعة أذونات التطبيق", + "permissionSet.modal.edit.dataset.description": "عدّل الاسم والوصف والأذونات الممنوحة لمجموعة الأذونات هذه.", + "permissionSet.modal.edit.dataset.title": "تعديل مجموعة أذونات قاعدة المعرفة", + "permissionSet.modal.view.app.description": "اعرض الاسم والوصف والأذونات الممنوحة لمجموعة الأذونات هذه.", + "permissionSet.modal.view.app.title": "عرض مجموعة أذونات التطبيق", + "permissionSet.modal.view.dataset.description": "اعرض الاسم والوصف والأذونات الممنوحة لمجموعة الأذونات هذه.", + "permissionSet.modal.view.dataset.title": "عرض مجموعة أذونات قاعدة المعرفة", + "permissionSet.nameLabel": "اسم مجموعة الأذونات", + "permissionSet.namePlaceholder": "مثال: يمكنه تصدير DSL", + "permissionSet.permissions": "الأذونات", + "role.addRole": "إنشاء الأدوار", + "role.copyMembersDescription_one": "\"{{name}}\" مُعيَّن لعضو {{count}}. هل تريد أن تتضمن نسخة الدور الجديد العضو نفسه؟", + "role.copyMembersDescription_other": "\"{{name}}\" مُعيَّن لـ {{count}} أعضاء. هل تريد أن تتضمن نسخة الدور الجديد الأعضاء أنفسهم؟", + "role.copyMembersLoading": "جارٍ تحميل تعيينات الأعضاء...", + "role.copyMembersTitle": "نسخ تعيينات الأعضاء؟", + "role.created": "تم إنشاء الدور بنجاح", + "role.deleteDescription": "سيتم حذف هذا الدور نهائيًا وإزالته من أي أعضاء أو قواعد وصول تستخدمه.", + "role.deleteTitle": "حذف \"{{name}}\"؟", + "role.deleted": "تم حذف الدور بنجاح", + "role.duplicated": "تم تكرار الدور بنجاح", + "role.groups.builtin": "أدوار النظام", + "role.groups.custom": "الأدوار المخصصة", + "role.loading": "جارٍ تحميل الأدوار...", + "role.modal.create.description": "إنشاء دور وتعيين الأذونات", + "role.modal.create.title": "إنشاء دور", + "role.modal.descriptionLabel": "الوصف", + "role.modal.descriptionPlaceholder": "صف ما هذا الدور مسؤول عنه", + "role.modal.edit.description": "تعديل تفاصيل الدور والأذونات", + "role.modal.edit.title": "تعديل الدور", + "role.modal.nameLabel": "اسم الدور", + "role.modal.namePlaceholder": "مثال: قائد التسويق", + "role.modal.view.description": "عرض تفاصيل الدور والأذونات", + "role.modal.view.title": "عرض الدور", + "role.noDescription": "لا يوجد وصف", + "role.noMatchingRoles": "لا توجد أدوار مطابقة", + "role.searchPlaceholder": "البحث في الأدوار...", + "role.updated": "تم تحديث الدور بنجاح", + "role.workspaceRoles.description": "أنشئ أدوارًا وحدد ما يمكن لكل دور فعله في مساحة العمل هذه.", + "role.workspaceRoles.title": "أدوار مساحة العمل" +} diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json new file mode 100644 index 00000000000..5b42c701c9e --- /dev/null +++ b/web/i18n/de-DE/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "API-Erweiterungskonfiguration verwalten", + "app.access_config": "App-Zugriffsberechtigungen konfigurieren", + "app.acl.access_config": "App-Zugriffsberechtigungen konfigurieren", + "app.acl.delete": "App löschen", + "app.acl.edit": "App bearbeiten und orchestrieren", + "app.acl.import_export_dsl": "DSL importieren / exportieren", + "app.acl.monitor": "Überwachung und Betrieb", + "app.acl.preview": "App-Vorschau", + "app.acl.release_and_version": "App-Veröffentlichung und Versionsverwaltung", + "app.acl.test_and_run": "App testen und verwenden", + "app.acl.view_layout": "Schreibgeschützte Orchestrierungsseite", + "app.create_and_management": "Apps erstellen und von Ihnen erstellte Apps verwalten", + "app.tag.manage": "App-Tags verwalten", + "app_library.access": "Auf App-Bibliothek zugreifen", + "billing.manage": "Abonnementpläne ändern", + "billing.subscription.manage": "Abrechnung und Abonnements im Abrechnungsportal verwalten", + "billing.view": "Auf Abrechnungseinstellungen zugreifen", + "credential.create": "Anmeldeinformationen hinzufügen", + "credential.manage": "Anmeldeinformationen bearbeiten und löschen", + "credential.use": "Anmeldeinformationen anzeigen und verwenden", + "customization.manage": "Anpassung verwalten", + "data_source.manage": "Datenquellenkonfiguration verwalten", + "dataset.access_config": "Zugriffsberechtigungen der Wissensdatenbank konfigurieren", + "dataset.acl.access_config": "Zugriffsberechtigungen der Wissensdatenbank konfigurieren", + "dataset.acl.delete": "Wissensdatenbank löschen", + "dataset.acl.delete_file": "Wissensdatenbankdateien löschen", + "dataset.acl.document_download": "Dokumente herunterladen", + "dataset.acl.edit": "Wissensdatenbank bearbeiten", + "dataset.acl.import_export_dsl": "Knowledge-Pipeline-DSL importieren / exportieren", + "dataset.acl.pipeline_release": "Knowledge-Pipeline-Veröffentlichung und Versionsverwaltung", + "dataset.acl.pipeline_test": "Pipeline-Tests", + "dataset.acl.preview": "Wissensdatenbank-Vorschau", + "dataset.acl.readonly": "Schreibgeschützte Wissensdatenbank", + "dataset.acl.retrieval_recall": "Wissensdatenbank-Abruf", + "dataset.acl.use": "Dokumente zur Wissensdatenbank hinzufügen", + "dataset.api_key.manage": "API-Schlüssel der Wissensdatenbank verwalten", + "dataset.create_and_management": "Wissensdatenbanken erstellen und von Ihnen erstellte Wissensdatenbanken verwalten", + "dataset.external.connect": "Externe Wissensdatenbanken verbinden", + "dataset.tag.manage": "Wissensdatenbank-Tags verwalten", + "mcp.manage": "MCP verwalten", + "plugin.debug": "Plugins debuggen", + "plugin.install": "Plugins installieren und aktualisieren", + "plugin.manage": "Plugins verwalten", + "plugin.plugin_preferences": "Plugin-Einstellungen verwalten", + "snippets.create_and_modify": "Snippets erstellen und ändern", + "snippets.management": "Snippets verwalten", + "tool.manage": "Tools verwalten", + "workspace.member.manage": "Mitglieder verwalten", + "workspace.role.manage": "Rollenberechtigungen und Ressourcenzugriffsregeln verwalten" +} diff --git a/web/i18n/de-DE/permission.json b/web/i18n/de-DE/permission.json new file mode 100644 index 00000000000..ef7121b5b29 --- /dev/null +++ b/web/i18n/de-DE/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Aktionen", + "accessRule.addMemberAria": "{{name}} hinzufügen", + "accessRule.addMembersTitle": "Mitglieder hinzufügen", + "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.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.defaultPermission": "Nach Rollenberechtigungen", + "accessRule.deleteDescription": "Diese Zugriffsregel wird dauerhaft gelöscht und aus der Ressourcen-Autorisierungsliste entfernt.", + "accessRule.deleteTitle": "\"{{name}}\" löschen?", + "accessRule.deleted": "Zugriffsregel erfolgreich gelöscht", + "accessRule.exceptionPermissionFor": "Ausnahmeberechtigung für {{name}}", + "accessRule.expandSection": "{{title}} ausklappen", + "accessRule.individualPermissionSettings": "Individuelle Berechtigungseinstellungen", + "accessRule.individualPermissionSettingsTip": "Legen Sie Berechtigungsausnahmen für bestimmte Mitarbeiter oder Gruppen fest. Diese Einstellungen überschreiben die Standardzugriffsstufe.", + "accessRule.lockedSummary_one": "· {{count}} gesperrt", + "accessRule.lockedSummary_other": "· {{count}} gesperrt", + "accessRule.maintainer": "Betreuer", + "accessRule.member": "Mitglied", + "accessRule.newPermissionSet": "Neuer Berechtigungssatz", + "accessRule.noAvailableMembers": "Keine Mitglieder zum Hinzufügen verfügbar", + "accessRule.noDescription": "Keine Beschreibung", + "accessRule.noRoles": "Keine Rollen", + "accessRule.noRules": "Keine Zugriffsregeln", + "accessRule.noUserAccessSettings": "Keine individuellen Berechtigungseinstellungen", + "accessRule.permission": "Berechtigung", + "accessRule.resourceOpenScope": "Freigabebereich der Ressource", + "accessRule.resourceOpenScopeDescription": "Wählen Sie, für wen diese Ressource geöffnet ist. Rollenberechtigungen bestimmen weiterhin, was jedes Mitglied tun kann.", + "accessRule.specificMembersOnly": "Nur bestimmte Mitglieder", + "accessRule.specificMembersOnlyDescription": "Nur ausgewählte Mitglieder können auf diese Ressource zugreifen.", + "accessRule.summary_one": "{{count}} Berechtigungssatz", + "accessRule.summary_other": "{{count}} Berechtigungssätze", + "accessRule.updated": "Zugriffsregel erfolgreich aktualisiert", + "common.duplicateAction": "Duplizieren", + "group.app": "Anwendungen", + "group.app_acl": "App-Zugriffsberechtigungen", + "group.billing": "Abrechnung", + "group.credential": "Anmeldeinformationen", + "group.dataset": "Wissensdatenbanken", + "group.dataset_acl": "Zugriffsberechtigungen der Wissensdatenbank", + "group.integration": "Integrationen", + "group.plugin": "Plugins", + "group.tool_mcp": "Tools und MCP", + "group.workspace": "Arbeitsbereich", + "permissionList.clearAll": "Alle löschen", + "permissionList.collapseGroup": "Gruppe einklappen", + "permissionList.expandGroup": "Gruppe ausklappen", + "permissionList.noPermissionsFound": "Keine Berechtigungen gefunden", + "permissionList.selectAll": "Alle auswählen", + "permissionSet.descriptionLabel": "Beschreibung", + "permissionSet.descriptionPlaceholder": "Beschreiben Sie, was dieser Berechtigungssatz gewährt", + "permissionSet.learnMore": "Mehr über Berechtigungen erfahren", + "permissionSet.modal.create.app.description": "Erstellen Sie einen App-Berechtigungssatz, der in Zugriffsregeln zur schnellen Autorisierung referenziert werden kann.", + "permissionSet.modal.create.app.title": "App-Berechtigungssatz erstellen", + "permissionSet.modal.create.dataset.description": "Erstellen Sie einen Wissensdatenbank-Berechtigungssatz, der in Zugriffsregeln zur schnellen Autorisierung referenziert werden kann.", + "permissionSet.modal.create.dataset.title": "Wissensdatenbank-Berechtigungssatz erstellen", + "permissionSet.modal.edit.app.description": "Ändern Sie den Namen, die Beschreibung und die für diesen Berechtigungssatz gewährten Berechtigungen.", + "permissionSet.modal.edit.app.title": "App-Berechtigungssatz bearbeiten", + "permissionSet.modal.edit.dataset.description": "Ändern Sie den Namen, die Beschreibung und die für diesen Berechtigungssatz gewährten Berechtigungen.", + "permissionSet.modal.edit.dataset.title": "Wissensdatenbank-Berechtigungssatz bearbeiten", + "permissionSet.modal.view.app.description": "Sehen Sie den Namen, die Beschreibung und die für diesen Berechtigungssatz gewährten Berechtigungen.", + "permissionSet.modal.view.app.title": "App-Berechtigungssatz anzeigen", + "permissionSet.modal.view.dataset.description": "Sehen Sie den Namen, die Beschreibung und die für diesen Berechtigungssatz gewährten Berechtigungen.", + "permissionSet.modal.view.dataset.title": "Wissensdatenbank-Berechtigungssatz anzeigen", + "permissionSet.nameLabel": "Name des Berechtigungssatzes", + "permissionSet.namePlaceholder": "z. B. Kann DSL exportieren", + "permissionSet.permissions": "Berechtigungen", + "role.addRole": "Rollen erstellen", + "role.copyMembersDescription_one": "\"{{name}}\" ist {{count}} Mitglied zugewiesen. Möchten Sie, dass die neue Rollenkopie dasselbe Mitglied enthält?", + "role.copyMembersDescription_other": "\"{{name}}\" ist {{count}} Mitgliedern zugewiesen. Möchten Sie, dass die neue Rollenkopie dieselben Mitglieder enthält?", + "role.copyMembersLoading": "Mitgliederzuweisungen werden geladen...", + "role.copyMembersTitle": "Mitgliederzuweisungen kopieren?", + "role.created": "Rolle erfolgreich erstellt", + "role.deleteDescription": "Diese Rolle wird dauerhaft gelöscht und von allen Mitgliedern oder Zugriffsregeln entfernt, die sie verwenden.", + "role.deleteTitle": "\"{{name}}\" löschen?", + "role.deleted": "Rolle erfolgreich gelöscht", + "role.duplicated": "Rolle erfolgreich dupliziert", + "role.groups.builtin": "Systemrollen", + "role.groups.custom": "Benutzerdefinierte Rollen", + "role.loading": "Rollen werden geladen...", + "role.modal.create.description": "Erstellen Sie eine Rolle und weisen Sie Berechtigungen zu", + "role.modal.create.title": "Rolle erstellen", + "role.modal.descriptionLabel": "Beschreibung", + "role.modal.descriptionPlaceholder": "Beschreiben Sie, wofür diese Rolle zuständig ist", + "role.modal.edit.description": "Rollendetails und Berechtigungen bearbeiten", + "role.modal.edit.title": "Rolle bearbeiten", + "role.modal.nameLabel": "Rollenname", + "role.modal.namePlaceholder": "z. B. Marketing-Leitung", + "role.modal.view.description": "Rollendetails und Berechtigungen anzeigen", + "role.modal.view.title": "Rolle anzeigen", + "role.noDescription": "Keine Beschreibung", + "role.noMatchingRoles": "Keine passenden Rollen", + "role.searchPlaceholder": "Rollen suchen...", + "role.updated": "Rolle erfolgreich aktualisiert", + "role.workspaceRoles.description": "Erstellen Sie Rollen und definieren Sie, was jede Rolle in diesem Arbeitsbereich tun kann.", + "role.workspaceRoles.title": "Arbeitsbereichsrollen" +} diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json new file mode 100644 index 00000000000..3b52ba33826 --- /dev/null +++ b/web/i18n/es-ES/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Gestionar la configuración de la extensión de API", + "app.access_config": "Configurar los permisos de acceso de la app", + "app.acl.access_config": "Configurar los permisos de acceso de la app", + "app.acl.delete": "Eliminar app", + "app.acl.edit": "Editar y orquestar la app", + "app.acl.import_export_dsl": "Importar / exportar DSL", + "app.acl.monitor": "Supervisión y operaciones", + "app.acl.preview": "Previsualizar app", + "app.acl.release_and_version": "Publicación de la app y gestión de versiones", + "app.acl.test_and_run": "Probar y usar la app", + "app.acl.view_layout": "Página de orquestación de solo lectura", + "app.create_and_management": "Crear apps y gestionar las apps que has creado", + "app.tag.manage": "Gestionar etiquetas de apps", + "app_library.access": "Acceder a la Biblioteca de apps", + "billing.manage": "Cambiar planes de suscripción", + "billing.subscription.manage": "Gestionar la facturación y las suscripciones en el portal de facturación", + "billing.view": "Acceder a la configuración de facturación", + "credential.create": "Añadir credenciales", + "credential.manage": "Editar y eliminar credenciales", + "credential.use": "Ver y usar credenciales", + "customization.manage": "Gestionar la personalización", + "data_source.manage": "Gestionar la configuración de la fuente de datos", + "dataset.access_config": "Configurar los permisos de acceso de la base de conocimiento", + "dataset.acl.access_config": "Configurar los permisos de acceso de la base de conocimiento", + "dataset.acl.delete": "Eliminar base de conocimiento", + "dataset.acl.delete_file": "Eliminar archivos de la base de conocimiento", + "dataset.acl.document_download": "Descargar documentos", + "dataset.acl.edit": "Editar base de conocimiento", + "dataset.acl.import_export_dsl": "Importar / exportar el DSL del pipeline de conocimiento", + "dataset.acl.pipeline_release": "Publicación del pipeline de conocimiento y gestión de versiones", + "dataset.acl.pipeline_test": "Pruebas del pipeline", + "dataset.acl.preview": "Previsualizar base de conocimiento", + "dataset.acl.readonly": "Base de conocimiento de solo lectura", + "dataset.acl.retrieval_recall": "Recuperación de la base de conocimiento", + "dataset.acl.use": "Añadir documentos a la base de conocimiento", + "dataset.api_key.manage": "Gestionar las claves de API de la base de conocimiento", + "dataset.create_and_management": "Crear bases de conocimiento y gestionar las bases de conocimiento que has creado", + "dataset.external.connect": "Conectar bases de conocimiento externas", + "dataset.tag.manage": "Gestionar etiquetas de la base de conocimiento", + "mcp.manage": "Gestionar MCP", + "plugin.debug": "Depurar plugins", + "plugin.install": "Instalar y actualizar plugins", + "plugin.manage": "Gestionar plugins", + "plugin.plugin_preferences": "Gestionar las preferencias de plugins", + "snippets.create_and_modify": "Crear y modificar fragmentos", + "snippets.management": "Gestionar fragmentos", + "tool.manage": "Gestionar herramientas", + "workspace.member.manage": "Gestionar miembros", + "workspace.role.manage": "Gestionar permisos de roles y reglas de acceso a recursos" +} diff --git a/web/i18n/es-ES/permission.json b/web/i18n/es-ES/permission.json new file mode 100644 index 00000000000..88ad805b1fb --- /dev/null +++ b/web/i18n/es-ES/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Acciones", + "accessRule.addMemberAria": "Añadir {{name}}", + "accessRule.addMembersTitle": "Añadir miembros", + "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.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.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}}\"?", + "accessRule.deleted": "Regla de acceso eliminada correctamente", + "accessRule.exceptionPermissionFor": "Permiso de excepción para {{name}}", + "accessRule.expandSection": "Expandir {{title}}", + "accessRule.individualPermissionSettings": "Ajustes de permisos individuales", + "accessRule.individualPermissionSettingsTip": "Establece excepciones de permisos para colaboradores o grupos específicos. Estos ajustes anulan el nivel de acceso predeterminado.", + "accessRule.lockedSummary_one": "· {{count}} bloqueado", + "accessRule.lockedSummary_other": "· {{count}} bloqueados", + "accessRule.maintainer": "Mantenedor", + "accessRule.member": "Miembro", + "accessRule.newPermissionSet": "Nuevo conjunto de permisos", + "accessRule.noAvailableMembers": "No hay miembros disponibles para añadir", + "accessRule.noDescription": "Sin descripción", + "accessRule.noRoles": "Sin roles", + "accessRule.noRules": "Sin reglas de acceso", + "accessRule.noUserAccessSettings": "Sin ajustes de permisos individuales", + "accessRule.permission": "Permiso", + "accessRule.resourceOpenScope": "Ámbito de apertura del recurso", + "accessRule.resourceOpenScopeDescription": "Elige a quién está abierto este recurso. Los permisos de rol siguen decidiendo lo que puede hacer cada miembro.", + "accessRule.specificMembersOnly": "Solo miembros específicos", + "accessRule.specificMembersOnlyDescription": "Solo los miembros seleccionados pueden acceder a este recurso.", + "accessRule.summary_one": "{{count}} conjunto de permisos", + "accessRule.summary_other": "{{count}} conjuntos de permisos", + "accessRule.updated": "Regla de acceso actualizada correctamente", + "common.duplicateAction": "Duplicar", + "group.app": "Aplicaciones", + "group.app_acl": "Permisos de acceso de la app", + "group.billing": "Facturación", + "group.credential": "Credenciales", + "group.dataset": "Bases de conocimiento", + "group.dataset_acl": "Permisos de acceso de la base de conocimiento", + "group.integration": "Integraciones", + "group.plugin": "Plugins", + "group.tool_mcp": "Herramientas y MCP", + "group.workspace": "Espacio de trabajo", + "permissionList.clearAll": "Borrar todo", + "permissionList.collapseGroup": "Contraer grupo", + "permissionList.expandGroup": "Expandir grupo", + "permissionList.noPermissionsFound": "No se encontraron permisos", + "permissionList.selectAll": "Seleccionar todo", + "permissionSet.descriptionLabel": "Descripción", + "permissionSet.descriptionPlaceholder": "Describe qué concede este conjunto de permisos", + "permissionSet.learnMore": "Más información sobre los permisos", + "permissionSet.modal.create.app.description": "Crea un conjunto de permisos de app que se pueda referenciar en las reglas de acceso para una autorización rápida.", + "permissionSet.modal.create.app.title": "Crear conjunto de permisos de app", + "permissionSet.modal.create.dataset.description": "Crea un conjunto de permisos de base de conocimiento que se pueda referenciar en las reglas de acceso para una autorización rápida.", + "permissionSet.modal.create.dataset.title": "Crear conjunto de permisos de base de conocimiento", + "permissionSet.modal.edit.app.description": "Modifica el nombre, la descripción y los permisos concedidos para este conjunto de permisos.", + "permissionSet.modal.edit.app.title": "Editar conjunto de permisos de app", + "permissionSet.modal.edit.dataset.description": "Modifica el nombre, la descripción y los permisos concedidos para este conjunto de permisos.", + "permissionSet.modal.edit.dataset.title": "Editar conjunto de permisos de base de conocimiento", + "permissionSet.modal.view.app.description": "Consulta el nombre, la descripción y los permisos concedidos para este conjunto de permisos.", + "permissionSet.modal.view.app.title": "Ver conjunto de permisos de app", + "permissionSet.modal.view.dataset.description": "Consulta el nombre, la descripción y los permisos concedidos para este conjunto de permisos.", + "permissionSet.modal.view.dataset.title": "Ver conjunto de permisos de base de conocimiento", + "permissionSet.nameLabel": "Nombre del conjunto de permisos", + "permissionSet.namePlaceholder": "p. ej. Puede exportar DSL", + "permissionSet.permissions": "Permisos", + "role.addRole": "Crear roles", + "role.copyMembersDescription_one": "\"{{name}}\" está asignado a {{count}} miembro. ¿Quieres que la nueva copia del rol incluya el mismo miembro?", + "role.copyMembersDescription_other": "\"{{name}}\" está asignado a {{count}} miembros. ¿Quieres que la nueva copia del rol incluya los mismos miembros?", + "role.copyMembersLoading": "Cargando asignaciones de miembros...", + "role.copyMembersTitle": "¿Copiar asignaciones de miembros?", + "role.created": "Rol creado correctamente", + "role.deleteDescription": "Este rol se eliminará de forma permanente y se quitará de cualquier miembro o regla de acceso que lo use.", + "role.deleteTitle": "¿Eliminar \"{{name}}\"?", + "role.deleted": "Rol eliminado correctamente", + "role.duplicated": "Rol duplicado correctamente", + "role.groups.builtin": "Roles del sistema", + "role.groups.custom": "Roles personalizados", + "role.loading": "Cargando roles...", + "role.modal.create.description": "Crea un rol y asigna permisos", + "role.modal.create.title": "Crear rol", + "role.modal.descriptionLabel": "Descripción", + "role.modal.descriptionPlaceholder": "Describe de qué es responsable este rol", + "role.modal.edit.description": "Editar los detalles y permisos del rol", + "role.modal.edit.title": "Editar rol", + "role.modal.nameLabel": "Nombre del rol", + "role.modal.namePlaceholder": "p. ej. Responsable de marketing", + "role.modal.view.description": "Ver los detalles y permisos del rol", + "role.modal.view.title": "Ver rol", + "role.noDescription": "Sin descripción", + "role.noMatchingRoles": "No hay roles coincidentes", + "role.searchPlaceholder": "Buscar roles...", + "role.updated": "Rol actualizado correctamente", + "role.workspaceRoles.description": "Crea roles y define lo que cada rol puede hacer en este espacio de trabajo.", + "role.workspaceRoles.title": "Roles del espacio de trabajo" +} diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json new file mode 100644 index 00000000000..c1773da4424 --- /dev/null +++ b/web/i18n/fa-IR/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "مدیریت پیکربندی افزونه API", + "app.access_config": "پیکربندی مجوزهای دسترسی برنامه", + "app.acl.access_config": "پیکربندی مجوزهای دسترسی برنامه", + "app.acl.delete": "حذف برنامه", + "app.acl.edit": "ویرایش و هماهنگ‌سازی برنامه", + "app.acl.import_export_dsl": "وارد کردن / صادر کردن DSL", + "app.acl.monitor": "نظارت و عملیات", + "app.acl.preview": "پیش‌نمایش برنامه", + "app.acl.release_and_version": "انتشار برنامه و مدیریت نسخه", + "app.acl.test_and_run": "آزمایش و استفاده از برنامه", + "app.acl.view_layout": "صفحه هماهنگ‌سازی فقط‌خواندنی", + "app.create_and_management": "ایجاد برنامه‌ها و مدیریت برنامه‌هایی که ایجاد کرده‌اید", + "app.tag.manage": "مدیریت برچسب‌های برنامه", + "app_library.access": "دسترسی به کتابخانه برنامه", + "billing.manage": "تغییر طرح‌های اشتراک", + "billing.subscription.manage": "مدیریت صورت‌حساب و اشتراک‌ها در پورتال صورت‌حساب", + "billing.view": "دسترسی به تنظیمات صورت‌حساب", + "credential.create": "افزودن اعتبارنامه‌ها", + "credential.manage": "ویرایش و حذف اعتبارنامه‌ها", + "credential.use": "مشاهده و استفاده از اعتبارنامه‌ها", + "customization.manage": "مدیریت سفارشی‌سازی", + "data_source.manage": "مدیریت پیکربندی منبع داده", + "dataset.access_config": "پیکربندی مجوزهای دسترسی پایگاه دانش", + "dataset.acl.access_config": "پیکربندی مجوزهای دسترسی پایگاه دانش", + "dataset.acl.delete": "حذف پایگاه دانش", + "dataset.acl.delete_file": "حذف فایل‌های پایگاه دانش", + "dataset.acl.document_download": "دانلود اسناد", + "dataset.acl.edit": "ویرایش پایگاه دانش", + "dataset.acl.import_export_dsl": "وارد کردن / صادر کردن DSL خط لوله دانش", + "dataset.acl.pipeline_release": "انتشار خط لوله دانش و مدیریت نسخه", + "dataset.acl.pipeline_test": "آزمایش خط لوله", + "dataset.acl.preview": "پیش‌نمایش پایگاه دانش", + "dataset.acl.readonly": "پایگاه دانش فقط‌خواندنی", + "dataset.acl.retrieval_recall": "بازیابی پایگاه دانش", + "dataset.acl.use": "افزودن اسناد به پایگاه دانش", + "dataset.api_key.manage": "مدیریت کلیدهای API پایگاه دانش", + "dataset.create_and_management": "ایجاد پایگاه‌های دانش و مدیریت پایگاه‌های دانشی که ایجاد کرده‌اید", + "dataset.external.connect": "اتصال به پایگاه‌های دانش خارجی", + "dataset.tag.manage": "مدیریت برچسب‌های پایگاه دانش", + "mcp.manage": "مدیریت MCP", + "plugin.debug": "اشکال‌زدایی افزونه‌ها", + "plugin.install": "نصب و به‌روزرسانی افزونه‌ها", + "plugin.manage": "مدیریت افزونه‌ها", + "plugin.plugin_preferences": "مدیریت ترجیحات افزونه", + "snippets.create_and_modify": "ایجاد و اصلاح قطعه‌کدها", + "snippets.management": "مدیریت قطعه‌کدها", + "tool.manage": "مدیریت ابزارها", + "workspace.member.manage": "مدیریت اعضا", + "workspace.role.manage": "مدیریت مجوزهای نقش و قوانین دسترسی به منابع" +} diff --git a/web/i18n/fa-IR/permission.json b/web/i18n/fa-IR/permission.json new file mode 100644 index 00000000000..548e0257435 --- /dev/null +++ b/web/i18n/fa-IR/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "اقدامات", + "accessRule.addMemberAria": "افزودن {{name}}", + "accessRule.addMembersTitle": "افزودن اعضا", + "accessRule.allPermittedMembers": "همه اعضای دارای مجوزهای نقش", + "accessRule.allPermittedMembersDescription": "اعضای دارای مجوزهای نقش منطبق می‌توانند به این منبع دسترسی داشته باشند.", + "accessRule.appDescription": "کنترل کنید این برنامه برای چه کسانی باز است. اعضا همچنان برای مشاهده یا کار با آن به مجوزهای نقش نیاز دارند.", + "accessRule.appTitle": "قوانین دسترسی برنامه", + "accessRule.changeOpenScopeDescription": "تغییر دامنه دسترسی، همه تنظیمات مجوز فردی این منبع را بازنشانی می‌کند. پس از تغییر، باید مجوزهای خاص اعضا را دوباره اضافه کنید.", + "accessRule.changeOpenScopeTitle": "دامنه دسترسی منبع تغییر کند؟", + "accessRule.collapseSection": "جمع کردن {{title}}", + "accessRule.copied": "قانون دسترسی با موفقیت کپی شد", + "accessRule.created": "قانون دسترسی با موفقیت ایجاد شد", + "accessRule.datasetDescription": "کنترل کنید این پایگاه دانش برای چه کسانی باز است. اعضا همچنان برای مشاهده یا کار با آن به مجوزهای نقش نیاز دارند.", + "accessRule.datasetTitle": "قوانین دسترسی پایگاه دانش", + "accessRule.defaultPermission": "بر اساس مجوزهای نقش", + "accessRule.deleteDescription": "این قانون دسترسی به طور دائمی حذف شده و از فهرست مجوزدهی منبع برداشته می‌شود.", + "accessRule.deleteTitle": "حذف «{{name}}»؟", + "accessRule.deleted": "قانون دسترسی با موفقیت حذف شد", + "accessRule.exceptionPermissionFor": "مجوز استثنا برای {{name}}", + "accessRule.expandSection": "گسترش {{title}}", + "accessRule.individualPermissionSettings": "تنظیمات مجوز فردی", + "accessRule.individualPermissionSettingsTip": "استثناهای مجوز را برای همکاران یا گروه‌های خاص تنظیم کنید. این تنظیمات سطح دسترسی پیش‌فرض را لغو می‌کنند.", + "accessRule.lockedSummary_one": "· {{count}} قفل شده", + "accessRule.lockedSummary_other": "· {{count}} قفل شده", + "accessRule.maintainer": "نگهدارنده", + "accessRule.member": "عضو", + "accessRule.newPermissionSet": "مجموعه مجوز جدید", + "accessRule.noAvailableMembers": "هیچ عضوی برای افزودن در دسترس نیست", + "accessRule.noDescription": "بدون توضیحات", + "accessRule.noRoles": "بدون نقش", + "accessRule.noRules": "بدون قانون دسترسی", + "accessRule.noUserAccessSettings": "بدون تنظیمات مجوز فردی", + "accessRule.permission": "مجوز", + "accessRule.resourceOpenScope": "دامنه دسترسی منبع", + "accessRule.resourceOpenScopeDescription": "انتخاب کنید این منبع برای چه کسانی باز است. مجوزهای نقش همچنان تعیین می‌کنند هر عضو چه کاری می‌تواند انجام دهد.", + "accessRule.specificMembersOnly": "فقط اعضای خاص", + "accessRule.specificMembersOnlyDescription": "فقط اعضای انتخاب‌شده می‌توانند به این منبع دسترسی داشته باشند.", + "accessRule.summary_one": "{{count}} مجموعه مجوز", + "accessRule.summary_other": "{{count}} مجموعه مجوز", + "accessRule.updated": "قانون دسترسی با موفقیت به‌روزرسانی شد", + "common.duplicateAction": "تکثیر", + "group.app": "برنامه‌ها", + "group.app_acl": "مجوزهای دسترسی برنامه", + "group.billing": "صورت‌حساب", + "group.credential": "اعتبارنامه‌ها", + "group.dataset": "پایگاه‌های دانش", + "group.dataset_acl": "مجوزهای دسترسی پایگاه دانش", + "group.integration": "یکپارچه‌سازی‌ها", + "group.plugin": "افزونه‌ها", + "group.tool_mcp": "ابزارها و MCP", + "group.workspace": "فضای کاری", + "permissionList.clearAll": "پاک کردن همه", + "permissionList.collapseGroup": "جمع کردن گروه", + "permissionList.expandGroup": "گسترش گروه", + "permissionList.noPermissionsFound": "هیچ مجوزی یافت نشد", + "permissionList.selectAll": "انتخاب همه", + "permissionSet.descriptionLabel": "توضیحات", + "permissionSet.descriptionPlaceholder": "توضیح دهید این مجموعه مجوز چه چیزی را اعطا می‌کند", + "permissionSet.learnMore": "درباره مجوزها بیشتر بدانید", + "permissionSet.modal.create.app.description": "یک مجموعه مجوز برنامه ایجاد کنید که بتوان برای مجوزدهی سریع در قوانین دسترسی به آن ارجاع داد.", + "permissionSet.modal.create.app.title": "ایجاد مجموعه مجوز برنامه", + "permissionSet.modal.create.dataset.description": "یک مجموعه مجوز پایگاه دانش ایجاد کنید که بتوان برای مجوزدهی سریع در قوانین دسترسی به آن ارجاع داد.", + "permissionSet.modal.create.dataset.title": "ایجاد مجموعه مجوز پایگاه دانش", + "permissionSet.modal.edit.app.description": "نام، توضیحات و مجوزهای اعطاشده برای این مجموعه مجوز را اصلاح کنید.", + "permissionSet.modal.edit.app.title": "ویرایش مجموعه مجوز برنامه", + "permissionSet.modal.edit.dataset.description": "نام، توضیحات و مجوزهای اعطاشده برای این مجموعه مجوز را اصلاح کنید.", + "permissionSet.modal.edit.dataset.title": "ویرایش مجموعه مجوز پایگاه دانش", + "permissionSet.modal.view.app.description": "نام، توضیحات و مجوزهای اعطاشده برای این مجموعه مجوز را مشاهده کنید.", + "permissionSet.modal.view.app.title": "مشاهده مجموعه مجوز برنامه", + "permissionSet.modal.view.dataset.description": "نام، توضیحات و مجوزهای اعطاشده برای این مجموعه مجوز را مشاهده کنید.", + "permissionSet.modal.view.dataset.title": "مشاهده مجموعه مجوز پایگاه دانش", + "permissionSet.nameLabel": "نام مجموعه مجوز", + "permissionSet.namePlaceholder": "مثلاً می‌تواند DSL را صادر کند", + "permissionSet.permissions": "مجوزها", + "role.addRole": "ایجاد نقش‌ها", + "role.copyMembersDescription_one": "«{{name}}» به {{count}} عضو اختصاص داده شده است. آیا می‌خواهید نسخه نقش جدید همان عضو را در بر بگیرد؟", + "role.copyMembersDescription_other": "«{{name}}» به {{count}} عضو اختصاص داده شده است. آیا می‌خواهید نسخه نقش جدید همان اعضا را در بر بگیرد؟", + "role.copyMembersLoading": "در حال بارگذاری تخصیص‌های اعضا...", + "role.copyMembersTitle": "تخصیص‌های اعضا کپی شود؟", + "role.created": "نقش با موفقیت ایجاد شد", + "role.deleteDescription": "این نقش به طور دائمی حذف شده و از هر عضو یا قانون دسترسی که از آن استفاده می‌کند برداشته می‌شود.", + "role.deleteTitle": "حذف «{{name}}»؟", + "role.deleted": "نقش با موفقیت حذف شد", + "role.duplicated": "نقش با موفقیت تکثیر شد", + "role.groups.builtin": "نقش‌های سیستمی", + "role.groups.custom": "نقش‌های سفارشی", + "role.loading": "در حال بارگذاری نقش‌ها...", + "role.modal.create.description": "یک نقش ایجاد کنید و مجوزها را اختصاص دهید", + "role.modal.create.title": "ایجاد نقش", + "role.modal.descriptionLabel": "توضیحات", + "role.modal.descriptionPlaceholder": "توضیح دهید این نقش مسئول چه چیزی است", + "role.modal.edit.description": "ویرایش جزئیات و مجوزهای نقش", + "role.modal.edit.title": "ویرایش نقش", + "role.modal.nameLabel": "نام نقش", + "role.modal.namePlaceholder": "مثلاً سرپرست بازاریابی", + "role.modal.view.description": "مشاهده جزئیات و مجوزهای نقش", + "role.modal.view.title": "مشاهده نقش", + "role.noDescription": "بدون توضیحات", + "role.noMatchingRoles": "هیچ نقش منطبقی یافت نشد", + "role.searchPlaceholder": "جستجوی نقش‌ها...", + "role.updated": "نقش با موفقیت به‌روزرسانی شد", + "role.workspaceRoles.description": "نقش‌ها را ایجاد کنید و تعریف کنید هر نقش در این فضای کاری چه کاری می‌تواند انجام دهد.", + "role.workspaceRoles.title": "نقش‌های فضای کاری" +} diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json new file mode 100644 index 00000000000..759ff9b7e0a --- /dev/null +++ b/web/i18n/fr-FR/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Gérer la configuration de l'extension API", + "app.access_config": "Configurer les autorisations d'accès à l'application", + "app.acl.access_config": "Configurer les autorisations d'accès à l'application", + "app.acl.delete": "Supprimer l'application", + "app.acl.edit": "Modifier et orchestrer l'application", + "app.acl.import_export_dsl": "Importer / exporter le DSL", + "app.acl.monitor": "Surveillance et exploitation", + "app.acl.preview": "Prévisualiser l'application", + "app.acl.release_and_version": "Publication de l'application et gestion des versions", + "app.acl.test_and_run": "Tester et utiliser l'application", + "app.acl.view_layout": "Page d'orchestration en lecture seule", + "app.create_and_management": "Créer des applications et gérer les applications que vous avez créées", + "app.tag.manage": "Gérer les étiquettes d'applications", + "app_library.access": "Accéder à la bibliothèque d'applications", + "billing.manage": "Modifier les forfaits d'abonnement", + "billing.subscription.manage": "Gérer la facturation et les abonnements dans le portail de facturation", + "billing.view": "Accéder aux paramètres de facturation", + "credential.create": "Ajouter des identifiants", + "credential.manage": "Modifier et supprimer des identifiants", + "credential.use": "Afficher et utiliser des identifiants", + "customization.manage": "Gérer la personnalisation", + "data_source.manage": "Gérer la configuration des sources de données", + "dataset.access_config": "Configurer les autorisations d'accès à la base de connaissances", + "dataset.acl.access_config": "Configurer les autorisations d'accès à la base de connaissances", + "dataset.acl.delete": "Supprimer la base de connaissances", + "dataset.acl.delete_file": "Supprimer les fichiers de la base de connaissances", + "dataset.acl.document_download": "Télécharger des documents", + "dataset.acl.edit": "Modifier la base de connaissances", + "dataset.acl.import_export_dsl": "Importer / exporter le DSL du pipeline de connaissances", + "dataset.acl.pipeline_release": "Publication du pipeline de connaissances et gestion des versions", + "dataset.acl.pipeline_test": "Test du pipeline", + "dataset.acl.preview": "Prévisualiser la base de connaissances", + "dataset.acl.readonly": "Base de connaissances en lecture seule", + "dataset.acl.retrieval_recall": "Récupération dans la base de connaissances", + "dataset.acl.use": "Ajouter des documents à la base de connaissances", + "dataset.api_key.manage": "Gérer les clés API de la base de connaissances", + "dataset.create_and_management": "Créer des bases de connaissances et gérer les bases de connaissances que vous avez créées", + "dataset.external.connect": "Connecter des bases de connaissances externes", + "dataset.tag.manage": "Gérer les étiquettes de bases de connaissances", + "mcp.manage": "Gérer MCP", + "plugin.debug": "Déboguer les plugins", + "plugin.install": "Installer et mettre à jour les plugins", + "plugin.manage": "Gérer les plugins", + "plugin.plugin_preferences": "Gérer les préférences des plugins", + "snippets.create_and_modify": "Créer et modifier des extraits", + "snippets.management": "Gérer les extraits", + "tool.manage": "Gérer les outils", + "workspace.member.manage": "Gérer les membres", + "workspace.role.manage": "Gérer les autorisations de rôles et les règles d'accès aux ressources" +} diff --git a/web/i18n/fr-FR/permission.json b/web/i18n/fr-FR/permission.json new file mode 100644 index 00000000000..897d0f71d8a --- /dev/null +++ b/web/i18n/fr-FR/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Actions", + "accessRule.addMemberAria": "Ajouter {{name}}", + "accessRule.addMembersTitle": "Ajouter des membres", + "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.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.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}}\" ?", + "accessRule.deleted": "Règle d'accès supprimée avec succès", + "accessRule.exceptionPermissionFor": "Autorisation d'exception pour {{name}}", + "accessRule.expandSection": "Développer {{title}}", + "accessRule.individualPermissionSettings": "Paramètres d'autorisation individuels", + "accessRule.individualPermissionSettingsTip": "Définissez des exceptions d'autorisation pour des collaborateurs ou des groupes spécifiques. Ces paramètres remplacent le niveau d'accès par défaut.", + "accessRule.lockedSummary_one": "· {{count}} verrouillé", + "accessRule.lockedSummary_other": "· {{count}} verrouillés", + "accessRule.maintainer": "Mainteneur", + "accessRule.member": "Membre", + "accessRule.newPermissionSet": "Nouvel ensemble d'autorisations", + "accessRule.noAvailableMembers": "Aucun membre disponible à ajouter", + "accessRule.noDescription": "Aucune description", + "accessRule.noRoles": "Aucun rôle", + "accessRule.noRules": "Aucune règle d'accès", + "accessRule.noUserAccessSettings": "Aucun paramètre d'autorisation individuel", + "accessRule.permission": "Autorisation", + "accessRule.resourceOpenScope": "Portée d'ouverture de la ressource", + "accessRule.resourceOpenScopeDescription": "Choisissez à qui cette ressource est ouverte. Les autorisations de rôle déterminent toujours ce que chaque membre peut faire.", + "accessRule.specificMembersOnly": "Membres spécifiques uniquement", + "accessRule.specificMembersOnlyDescription": "Seuls les membres sélectionnés peuvent accéder à cette ressource.", + "accessRule.summary_one": "{{count}} ensemble d'autorisations", + "accessRule.summary_other": "{{count}} ensembles d'autorisations", + "accessRule.updated": "Règle d'accès mise à jour avec succès", + "common.duplicateAction": "Dupliquer", + "group.app": "Applications", + "group.app_acl": "Autorisations d'accès à l'application", + "group.billing": "Facturation", + "group.credential": "Identifiants", + "group.dataset": "Bases de connaissances", + "group.dataset_acl": "Autorisations d'accès à la base de connaissances", + "group.integration": "Intégrations", + "group.plugin": "Plugins", + "group.tool_mcp": "Outils et MCP", + "group.workspace": "Espace de travail", + "permissionList.clearAll": "Tout effacer", + "permissionList.collapseGroup": "Réduire le groupe", + "permissionList.expandGroup": "Développer le groupe", + "permissionList.noPermissionsFound": "Aucune autorisation trouvée", + "permissionList.selectAll": "Tout sélectionner", + "permissionSet.descriptionLabel": "Description", + "permissionSet.descriptionPlaceholder": "Décrivez ce que cet ensemble d'autorisations accorde", + "permissionSet.learnMore": "En savoir plus sur les autorisations", + "permissionSet.modal.create.app.description": "Créez un ensemble d'autorisations d'application qui peut être référencé dans les règles d'accès pour une autorisation rapide.", + "permissionSet.modal.create.app.title": "Créer un ensemble d'autorisations d'application", + "permissionSet.modal.create.dataset.description": "Créez un ensemble d'autorisations de base de connaissances qui peut être référencé dans les règles d'accès pour une autorisation rapide.", + "permissionSet.modal.create.dataset.title": "Créer un ensemble d'autorisations de base de connaissances", + "permissionSet.modal.edit.app.description": "Modifiez le nom, la description et les autorisations accordées pour cet ensemble d'autorisations.", + "permissionSet.modal.edit.app.title": "Modifier l'ensemble d'autorisations d'application", + "permissionSet.modal.edit.dataset.description": "Modifiez le nom, la description et les autorisations accordées pour cet ensemble d'autorisations.", + "permissionSet.modal.edit.dataset.title": "Modifier l'ensemble d'autorisations de base de connaissances", + "permissionSet.modal.view.app.description": "Affichez le nom, la description et les autorisations accordées pour cet ensemble d'autorisations.", + "permissionSet.modal.view.app.title": "Afficher l'ensemble d'autorisations d'application", + "permissionSet.modal.view.dataset.description": "Affichez le nom, la description et les autorisations accordées pour cet ensemble d'autorisations.", + "permissionSet.modal.view.dataset.title": "Afficher l'ensemble d'autorisations de base de connaissances", + "permissionSet.nameLabel": "Nom de l'ensemble d'autorisations", + "permissionSet.namePlaceholder": "ex. Peut exporter le DSL", + "permissionSet.permissions": "Autorisations", + "role.addRole": "Créer des rôles", + "role.copyMembersDescription_one": "\"{{name}}\" est attribué à {{count}} membre. Voulez-vous que la copie du nouveau rôle inclue le même membre ?", + "role.copyMembersDescription_other": "\"{{name}}\" est attribué à {{count}} membres. Voulez-vous que la copie du nouveau rôle inclue les mêmes membres ?", + "role.copyMembersLoading": "Chargement des attributions de membres...", + "role.copyMembersTitle": "Copier les attributions de membres ?", + "role.created": "Rôle créé avec succès", + "role.deleteDescription": "Ce rôle sera définitivement supprimé et retiré de tous les membres ou règles d'accès qui l'utilisent.", + "role.deleteTitle": "Supprimer \"{{name}}\" ?", + "role.deleted": "Rôle supprimé avec succès", + "role.duplicated": "Rôle dupliqué avec succès", + "role.groups.builtin": "Rôles système", + "role.groups.custom": "Rôles personnalisés", + "role.loading": "Chargement des rôles...", + "role.modal.create.description": "Créer un rôle et attribuer des autorisations", + "role.modal.create.title": "Créer un rôle", + "role.modal.descriptionLabel": "Description", + "role.modal.descriptionPlaceholder": "Décrivez de quoi ce rôle est responsable", + "role.modal.edit.description": "Modifier les détails et les autorisations du rôle", + "role.modal.edit.title": "Modifier le rôle", + "role.modal.nameLabel": "Nom du rôle", + "role.modal.namePlaceholder": "ex. Responsable marketing", + "role.modal.view.description": "Afficher les détails et les autorisations du rôle", + "role.modal.view.title": "Afficher le rôle", + "role.noDescription": "Aucune description", + "role.noMatchingRoles": "Aucun rôle correspondant", + "role.searchPlaceholder": "Rechercher des rôles...", + "role.updated": "Rôle mis à jour avec succès", + "role.workspaceRoles.description": "Créez des rôles et définissez ce que chaque rôle peut faire dans cet espace de travail.", + "role.workspaceRoles.title": "Rôles de l'espace de travail" +} diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json new file mode 100644 index 00000000000..7aec8a75945 --- /dev/null +++ b/web/i18n/hi-IN/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें", + "app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", + "app.acl.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", + "app.acl.delete": "ऐप हटाएं", + "app.acl.edit": "ऐप संपादित और ऑर्केस्ट्रेट करें", + "app.acl.import_export_dsl": "DSL आयात / निर्यात करें", + "app.acl.monitor": "निगरानी और संचालन", + "app.acl.preview": "ऐप पूर्वावलोकन करें", + "app.acl.release_and_version": "ऐप प्रकाशन और संस्करण प्रबंधन", + "app.acl.test_and_run": "ऐप परीक्षण और उपयोग करें", + "app.acl.view_layout": "केवल-पढ़ने योग्य ऑर्केस्ट्रेशन पृष्ठ", + "app.create_and_management": "ऐप्स बनाएं और अपने बनाए गए ऐप्स प्रबंधित करें", + "app.tag.manage": "ऐप टैग प्रबंधित करें", + "app_library.access": "ऐप लाइब्रेरी तक पहुंचें", + "billing.manage": "सदस्यता योजनाएं बदलें", + "billing.subscription.manage": "बिलिंग पोर्टल में बिलिंग और सदस्यताएं प्रबंधित करें", + "billing.view": "बिलिंग सेटिंग्स तक पहुंचें", + "credential.create": "क्रेडेंशियल जोड़ें", + "credential.manage": "क्रेडेंशियल संपादित और हटाएं", + "credential.use": "क्रेडेंशियल देखें और उपयोग करें", + "customization.manage": "अनुकूलन प्रबंधित करें", + "data_source.manage": "डेटा स्रोत कॉन्फ़िगरेशन प्रबंधित करें", + "dataset.access_config": "ज्ञान आधार एक्सेस अनुमतियाँ कॉन्फ़िगर करें", + "dataset.acl.access_config": "ज्ञान आधार एक्सेस अनुमतियाँ कॉन्फ़िगर करें", + "dataset.acl.delete": "ज्ञान आधार हटाएं", + "dataset.acl.delete_file": "ज्ञान आधार फ़ाइलें हटाएं", + "dataset.acl.document_download": "दस्तावेज़ डाउनलोड करें", + "dataset.acl.edit": "ज्ञान आधार संपादित करें", + "dataset.acl.import_export_dsl": "ज्ञान पाइपलाइन DSL आयात / निर्यात करें", + "dataset.acl.pipeline_release": "ज्ञान पाइपलाइन प्रकाशन और संस्करण प्रबंधन", + "dataset.acl.pipeline_test": "पाइपलाइन परीक्षण", + "dataset.acl.preview": "ज्ञान आधार पूर्वावलोकन करें", + "dataset.acl.readonly": "केवल-पढ़ने योग्य ज्ञान आधार", + "dataset.acl.retrieval_recall": "ज्ञान आधार पुनर्प्राप्ति", + "dataset.acl.use": "ज्ञान आधार में दस्तावेज़ जोड़ें", + "dataset.api_key.manage": "ज्ञान आधार API कुंजियाँ प्रबंधित करें", + "dataset.create_and_management": "ज्ञान आधार बनाएं और अपने बनाए गए ज्ञान आधार प्रबंधित करें", + "dataset.external.connect": "बाहरी ज्ञान आधार कनेक्ट करें", + "dataset.tag.manage": "ज्ञान आधार टैग प्रबंधित करें", + "mcp.manage": "MCP प्रबंधित करें", + "plugin.debug": "प्लगइन्स डिबग करें", + "plugin.install": "प्लगइन्स इंस्टॉल और अपडेट करें", + "plugin.manage": "प्लगइन्स प्रबंधित करें", + "plugin.plugin_preferences": "प्लगइन प्राथमिकताएं प्रबंधित करें", + "snippets.create_and_modify": "स्निपेट बनाएं और संशोधित करें", + "snippets.management": "स्निपेट प्रबंधित करें", + "tool.manage": "टूल प्रबंधित करें", + "workspace.member.manage": "सदस्य प्रबंधित करें", + "workspace.role.manage": "भूमिका अनुमतियाँ और संसाधन एक्सेस नियम प्रबंधित करें" +} diff --git a/web/i18n/hi-IN/permission.json b/web/i18n/hi-IN/permission.json new file mode 100644 index 00000000000..92ad3bbedcd --- /dev/null +++ b/web/i18n/hi-IN/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "क्रियाएं", + "accessRule.addMemberAria": "{{name}} जोड़ें", + "accessRule.addMembersTitle": "सदस्य जोड़ें", + "accessRule.allPermittedMembers": "भूमिका अनुमतियों वाले सभी सदस्य", + "accessRule.allPermittedMembersDescription": "मिलती-जुलती भूमिका अनुमतियों वाले सदस्य इस संसाधन तक पहुंच सकते हैं।", + "accessRule.appDescription": "नियंत्रित करें कि यह ऐप किसके लिए खुला है। सदस्यों को इसे देखने या संचालित करने के लिए अभी भी भूमिका अनुमतियों की आवश्यकता होती है।", + "accessRule.appTitle": "ऐप एक्सेस नियम", + "accessRule.changeOpenScopeDescription": "खुले दायरे को बदलने से इस संसाधन के लिए सभी व्यक्तिगत अनुमति सेटिंग्स रीसेट हो जाएंगी। स्विच करने के बाद आपको सदस्य-विशिष्ट अनुमतियां फिर से जोड़नी होंगी।", + "accessRule.changeOpenScopeTitle": "संसाधन का खुला दायरा बदलें?", + "accessRule.collapseSection": "{{title}} संक्षिप्त करें", + "accessRule.copied": "एक्सेस नियम सफलतापूर्वक कॉपी किया गया", + "accessRule.created": "एक्सेस नियम सफलतापूर्वक बनाया गया", + "accessRule.datasetDescription": "नियंत्रित करें कि यह ज्ञान आधार किसके लिए खुला है। सदस्यों को इसे देखने या संचालित करने के लिए अभी भी भूमिका अनुमतियों की आवश्यकता होती है।", + "accessRule.datasetTitle": "ज्ञान आधार एक्सेस नियम", + "accessRule.defaultPermission": "भूमिका अनुमतियों के अनुसार", + "accessRule.deleteDescription": "यह एक्सेस नियम स्थायी रूप से हटा दिया जाएगा और संसाधन प्राधिकरण सूची से हटा दिया जाएगा।", + "accessRule.deleteTitle": "\"{{name}}\" हटाएं?", + "accessRule.deleted": "एक्सेस नियम सफलतापूर्वक हटाया गया", + "accessRule.exceptionPermissionFor": "{{name}} के लिए अपवाद अनुमति", + "accessRule.expandSection": "{{title}} विस्तृत करें", + "accessRule.individualPermissionSettings": "व्यक्तिगत अनुमति सेटिंग्स", + "accessRule.individualPermissionSettingsTip": "विशिष्ट सहयोगियों या समूहों के लिए अनुमति अपवाद सेट करें। ये सेटिंग्स डिफ़ॉल्ट एक्सेस स्तर को ओवरराइड करती हैं।", + "accessRule.lockedSummary_one": "· {{count}} लॉक किया गया", + "accessRule.lockedSummary_other": "· {{count}} लॉक किए गए", + "accessRule.maintainer": "रखरखावकर्ता", + "accessRule.member": "सदस्य", + "accessRule.newPermissionSet": "नया अनुमति सेट", + "accessRule.noAvailableMembers": "जोड़ने के लिए कोई सदस्य उपलब्ध नहीं", + "accessRule.noDescription": "कोई विवरण नहीं", + "accessRule.noRoles": "कोई भूमिका नहीं", + "accessRule.noRules": "कोई एक्सेस नियम नहीं", + "accessRule.noUserAccessSettings": "कोई व्यक्तिगत अनुमति सेटिंग्स नहीं", + "accessRule.permission": "अनुमति", + "accessRule.resourceOpenScope": "संसाधन का खुला दायरा", + "accessRule.resourceOpenScopeDescription": "चुनें कि यह संसाधन किसके लिए खुला है। भूमिका अनुमतियां अभी भी तय करती हैं कि प्रत्येक सदस्य क्या कर सकता है।", + "accessRule.specificMembersOnly": "केवल विशिष्ट सदस्य", + "accessRule.specificMembersOnlyDescription": "केवल चयनित सदस्य ही इस संसाधन तक पहुंच सकते हैं।", + "accessRule.summary_one": "{{count}} अनुमति सेट", + "accessRule.summary_other": "{{count}} अनुमति सेट", + "accessRule.updated": "एक्सेस नियम सफलतापूर्वक अपडेट किया गया", + "common.duplicateAction": "डुप्लिकेट करें", + "group.app": "अनुप्रयोग", + "group.app_acl": "ऐप एक्सेस अनुमतियाँ", + "group.billing": "बिलिंग", + "group.credential": "क्रेडेंशियल", + "group.dataset": "ज्ञान आधार", + "group.dataset_acl": "ज्ञान आधार एक्सेस अनुमतियाँ", + "group.integration": "एकीकरण", + "group.plugin": "प्लगइन्स", + "group.tool_mcp": "टूल और MCP", + "group.workspace": "कार्यक्षेत्र", + "permissionList.clearAll": "सभी साफ़ करें", + "permissionList.collapseGroup": "समूह संक्षिप्त करें", + "permissionList.expandGroup": "समूह विस्तृत करें", + "permissionList.noPermissionsFound": "कोई अनुमति नहीं मिली", + "permissionList.selectAll": "सभी चुनें", + "permissionSet.descriptionLabel": "विवरण", + "permissionSet.descriptionPlaceholder": "वर्णन करें कि यह अनुमति सेट क्या प्रदान करता है", + "permissionSet.learnMore": "अनुमतियों के बारे में अधिक जानें", + "permissionSet.modal.create.app.description": "एक ऐप अनुमति सेट बनाएं जिसे त्वरित प्राधिकरण के लिए एक्सेस नियमों में संदर्भित किया जा सके।", + "permissionSet.modal.create.app.title": "ऐप अनुमति सेट बनाएं", + "permissionSet.modal.create.dataset.description": "एक ज्ञान आधार अनुमति सेट बनाएं जिसे त्वरित प्राधिकरण के लिए एक्सेस नियमों में संदर्भित किया जा सके।", + "permissionSet.modal.create.dataset.title": "ज्ञान आधार अनुमति सेट बनाएं", + "permissionSet.modal.edit.app.description": "इस अनुमति सेट के लिए नाम, विवरण और प्रदान की गई अनुमतियों को संशोधित करें।", + "permissionSet.modal.edit.app.title": "ऐप अनुमति सेट संपादित करें", + "permissionSet.modal.edit.dataset.description": "इस अनुमति सेट के लिए नाम, विवरण और प्रदान की गई अनुमतियों को संशोधित करें।", + "permissionSet.modal.edit.dataset.title": "ज्ञान आधार अनुमति सेट संपादित करें", + "permissionSet.modal.view.app.description": "इस अनुमति सेट के लिए नाम, विवरण और प्रदान की गई अनुमतियों को देखें।", + "permissionSet.modal.view.app.title": "ऐप अनुमति सेट देखें", + "permissionSet.modal.view.dataset.description": "इस अनुमति सेट के लिए नाम, विवरण और प्रदान की गई अनुमतियों को देखें।", + "permissionSet.modal.view.dataset.title": "ज्ञान आधार अनुमति सेट देखें", + "permissionSet.nameLabel": "अनुमति सेट नाम", + "permissionSet.namePlaceholder": "उदा. DSL निर्यात कर सकते हैं", + "permissionSet.permissions": "अनुमतियां", + "role.addRole": "भूमिकाएं बनाएं", + "role.copyMembersDescription_one": "\"{{name}}\" {{count}} सदस्य को सौंपी गई है। क्या आप चाहते हैं कि नई भूमिका प्रति में वही सदस्य शामिल हो?", + "role.copyMembersDescription_other": "\"{{name}}\" {{count}} सदस्यों को सौंपी गई है। क्या आप चाहते हैं कि नई भूमिका प्रति में वही सदस्य शामिल हों?", + "role.copyMembersLoading": "सदस्य असाइनमेंट लोड हो रहे हैं...", + "role.copyMembersTitle": "सदस्य असाइनमेंट कॉपी करें?", + "role.created": "भूमिका सफलतापूर्वक बनाई गई", + "role.deleteDescription": "यह भूमिका स्थायी रूप से हटा दी जाएगी और इसका उपयोग करने वाले किसी भी सदस्य या एक्सेस नियम से हटा दी जाएगी।", + "role.deleteTitle": "\"{{name}}\" हटाएं?", + "role.deleted": "भूमिका सफलतापूर्वक हटाई गई", + "role.duplicated": "भूमिका सफलतापूर्वक डुप्लिकेट की गई", + "role.groups.builtin": "सिस्टम भूमिकाएं", + "role.groups.custom": "कस्टम भूमिकाएं", + "role.loading": "भूमिकाएं लोड हो रही हैं...", + "role.modal.create.description": "एक भूमिका बनाएं और अनुमतियां असाइन करें", + "role.modal.create.title": "भूमिका बनाएं", + "role.modal.descriptionLabel": "विवरण", + "role.modal.descriptionPlaceholder": "वर्णन करें कि यह भूमिका किसके लिए जिम्मेदार है", + "role.modal.edit.description": "भूमिका विवरण और अनुमतियां संपादित करें", + "role.modal.edit.title": "भूमिका संपादित करें", + "role.modal.nameLabel": "भूमिका नाम", + "role.modal.namePlaceholder": "उदा. मार्केटिंग लीड", + "role.modal.view.description": "भूमिका विवरण और अनुमतियां देखें", + "role.modal.view.title": "भूमिका देखें", + "role.noDescription": "कोई विवरण नहीं", + "role.noMatchingRoles": "कोई मिलती-जुलती भूमिका नहीं", + "role.searchPlaceholder": "भूमिकाएं खोजें...", + "role.updated": "भूमिका सफलतापूर्वक अपडेट की गई", + "role.workspaceRoles.description": "भूमिकाएं बनाएं और परिभाषित करें कि प्रत्येक भूमिका इस कार्यक्षेत्र में क्या कर सकती है।", + "role.workspaceRoles.title": "कार्यक्षेत्र भूमिकाएं" +} diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json new file mode 100644 index 00000000000..c94d50274e2 --- /dev/null +++ b/web/i18n/id-ID/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Kelola konfigurasi ekstensi API", + "app.access_config": "Konfigurasikan izin akses aplikasi", + "app.acl.access_config": "Konfigurasikan izin akses aplikasi", + "app.acl.delete": "Hapus aplikasi", + "app.acl.edit": "Edit dan orkestrasikan aplikasi", + "app.acl.import_export_dsl": "Impor / ekspor DSL", + "app.acl.monitor": "Pemantauan dan operasi", + "app.acl.preview": "Pratinjau aplikasi", + "app.acl.release_and_version": "Penerbitan aplikasi dan manajemen versi", + "app.acl.test_and_run": "Uji dan gunakan aplikasi", + "app.acl.view_layout": "Halaman orkestrasi hanya-baca", + "app.create_and_management": "Buat aplikasi dan kelola aplikasi yang Anda buat", + "app.tag.manage": "Kelola tag aplikasi", + "app_library.access": "Akses Pustaka Aplikasi", + "billing.manage": "Ubah paket langganan", + "billing.subscription.manage": "Kelola penagihan dan langganan di portal penagihan", + "billing.view": "Akses pengaturan Penagihan", + "credential.create": "Tambahkan kredensial", + "credential.manage": "Edit dan hapus kredensial", + "credential.use": "Lihat dan gunakan kredensial", + "customization.manage": "Kelola penyesuaian", + "data_source.manage": "Kelola konfigurasi sumber data", + "dataset.access_config": "Konfigurasikan izin akses basis pengetahuan", + "dataset.acl.access_config": "Konfigurasikan izin akses basis pengetahuan", + "dataset.acl.delete": "Hapus basis pengetahuan", + "dataset.acl.delete_file": "Hapus berkas basis pengetahuan", + "dataset.acl.document_download": "Unduh dokumen", + "dataset.acl.edit": "Edit basis pengetahuan", + "dataset.acl.import_export_dsl": "Impor / ekspor DSL knowledge pipeline", + "dataset.acl.pipeline_release": "Penerbitan knowledge pipeline dan manajemen versi", + "dataset.acl.pipeline_test": "Pengujian pipeline", + "dataset.acl.preview": "Pratinjau basis pengetahuan", + "dataset.acl.readonly": "Basis pengetahuan hanya-baca", + "dataset.acl.retrieval_recall": "Pengambilan basis pengetahuan", + "dataset.acl.use": "Tambahkan dokumen ke basis pengetahuan", + "dataset.api_key.manage": "Kelola kunci API basis pengetahuan", + "dataset.create_and_management": "Buat basis pengetahuan dan kelola basis pengetahuan yang Anda buat", + "dataset.external.connect": "Hubungkan basis pengetahuan eksternal", + "dataset.tag.manage": "Kelola tag basis pengetahuan", + "mcp.manage": "Kelola MCP", + "plugin.debug": "Debug plugin", + "plugin.install": "Instal dan perbarui plugin", + "plugin.manage": "Kelola plugin", + "plugin.plugin_preferences": "Kelola preferensi plugin", + "snippets.create_and_modify": "Buat dan ubah snippet", + "snippets.management": "Kelola snippet", + "tool.manage": "Kelola alat", + "workspace.member.manage": "Kelola anggota", + "workspace.role.manage": "Kelola izin peran dan aturan akses sumber daya" +} diff --git a/web/i18n/id-ID/permission.json b/web/i18n/id-ID/permission.json new file mode 100644 index 00000000000..0b7ceca2a29 --- /dev/null +++ b/web/i18n/id-ID/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Tindakan", + "accessRule.addMemberAria": "Tambahkan {{name}}", + "accessRule.addMembersTitle": "Tambahkan anggota", + "accessRule.allPermittedMembers": "Semua anggota dengan izin peran", + "accessRule.allPermittedMembersDescription": "Anggota dengan izin peran yang cocok dapat mengakses sumber daya ini.", + "accessRule.appDescription": "Kontrol siapa yang dapat mengakses aplikasi ini. Anggota tetap memerlukan izin peran untuk melihat atau mengoperasikannya.", + "accessRule.appTitle": "Aturan Akses Aplikasi", + "accessRule.changeOpenScopeDescription": "Mengubah cakupan akses akan mengatur ulang semua pengaturan izin individu untuk sumber daya ini. Anda perlu menambahkan kembali izin khusus anggota setelah beralih.", + "accessRule.changeOpenScopeTitle": "Ubah cakupan akses sumber daya?", + "accessRule.collapseSection": "Ciutkan {{title}}", + "accessRule.copied": "Aturan akses berhasil disalin", + "accessRule.created": "Aturan akses berhasil dibuat", + "accessRule.datasetDescription": "Kontrol siapa yang dapat mengakses basis pengetahuan ini. Anggota tetap memerlukan izin peran untuk melihat atau mengoperasikannya.", + "accessRule.datasetTitle": "Aturan Akses Basis Pengetahuan", + "accessRule.defaultPermission": "Berdasarkan izin peran", + "accessRule.deleteDescription": "Aturan akses ini akan dihapus secara permanen dan dihapus dari daftar otorisasi sumber daya.", + "accessRule.deleteTitle": "Hapus \"{{name}}\"?", + "accessRule.deleted": "Aturan akses berhasil dihapus", + "accessRule.exceptionPermissionFor": "Izin pengecualian untuk {{name}}", + "accessRule.expandSection": "Perluas {{title}}", + "accessRule.individualPermissionSettings": "Pengaturan izin individu", + "accessRule.individualPermissionSettingsTip": "Tetapkan pengecualian izin untuk kolaborator atau grup tertentu. Pengaturan ini menggantikan tingkat akses default.", + "accessRule.lockedSummary_one": "· {{count}} terkunci", + "accessRule.lockedSummary_other": "· {{count}} terkunci", + "accessRule.maintainer": "Pengelola", + "accessRule.member": "Anggota", + "accessRule.newPermissionSet": "Set izin baru", + "accessRule.noAvailableMembers": "Tidak ada anggota yang tersedia untuk ditambahkan", + "accessRule.noDescription": "Tidak ada deskripsi", + "accessRule.noRoles": "Tidak ada peran", + "accessRule.noRules": "Tidak ada aturan akses", + "accessRule.noUserAccessSettings": "Tidak ada pengaturan izin individu", + "accessRule.permission": "Izin", + "accessRule.resourceOpenScope": "Cakupan akses sumber daya", + "accessRule.resourceOpenScopeDescription": "Pilih siapa yang dapat mengakses sumber daya ini. Izin peran tetap menentukan apa yang dapat dilakukan setiap anggota.", + "accessRule.specificMembersOnly": "Hanya anggota tertentu", + "accessRule.specificMembersOnlyDescription": "Hanya anggota yang dipilih yang dapat mengakses sumber daya ini.", + "accessRule.summary_one": "{{count}} set izin", + "accessRule.summary_other": "{{count}} set izin", + "accessRule.updated": "Aturan akses berhasil diperbarui", + "common.duplicateAction": "Duplikat", + "group.app": "Aplikasi", + "group.app_acl": "Izin akses aplikasi", + "group.billing": "Penagihan", + "group.credential": "Kredensial", + "group.dataset": "Basis pengetahuan", + "group.dataset_acl": "Izin akses basis pengetahuan", + "group.integration": "Integrasi", + "group.plugin": "Plugin", + "group.tool_mcp": "Alat dan MCP", + "group.workspace": "Ruang kerja", + "permissionList.clearAll": "Hapus semua", + "permissionList.collapseGroup": "Ciutkan grup", + "permissionList.expandGroup": "Perluas grup", + "permissionList.noPermissionsFound": "Tidak ada izin yang ditemukan", + "permissionList.selectAll": "Pilih semua", + "permissionSet.descriptionLabel": "Deskripsi", + "permissionSet.descriptionPlaceholder": "Jelaskan apa yang diberikan oleh set izin ini", + "permissionSet.learnMore": "Pelajari lebih lanjut tentang izin", + "permissionSet.modal.create.app.description": "Buat set izin aplikasi yang dapat dirujuk dalam aturan akses untuk otorisasi cepat.", + "permissionSet.modal.create.app.title": "Buat set izin Aplikasi", + "permissionSet.modal.create.dataset.description": "Buat set izin basis pengetahuan yang dapat dirujuk dalam aturan akses untuk otorisasi cepat.", + "permissionSet.modal.create.dataset.title": "Buat set izin Basis Pengetahuan", + "permissionSet.modal.edit.app.description": "Ubah nama, deskripsi, dan izin yang diberikan untuk set izin ini.", + "permissionSet.modal.edit.app.title": "Edit set izin Aplikasi", + "permissionSet.modal.edit.dataset.description": "Ubah nama, deskripsi, dan izin yang diberikan untuk set izin ini.", + "permissionSet.modal.edit.dataset.title": "Edit set izin Basis Pengetahuan", + "permissionSet.modal.view.app.description": "Lihat nama, deskripsi, dan izin yang diberikan untuk set izin ini.", + "permissionSet.modal.view.app.title": "Lihat set izin Aplikasi", + "permissionSet.modal.view.dataset.description": "Lihat nama, deskripsi, dan izin yang diberikan untuk set izin ini.", + "permissionSet.modal.view.dataset.title": "Lihat set izin Basis Pengetahuan", + "permissionSet.nameLabel": "Nama set izin", + "permissionSet.namePlaceholder": "mis. Dapat mengekspor DSL", + "permissionSet.permissions": "Izin", + "role.addRole": "Buat peran", + "role.copyMembersDescription_one": "\"{{name}}\" ditetapkan ke {{count}} anggota. Apakah Anda ingin salinan peran baru menyertakan anggota yang sama?", + "role.copyMembersDescription_other": "\"{{name}}\" ditetapkan ke {{count}} anggota. Apakah Anda ingin salinan peran baru menyertakan anggota yang sama?", + "role.copyMembersLoading": "Memuat penetapan anggota...", + "role.copyMembersTitle": "Salin penetapan anggota?", + "role.created": "Peran berhasil dibuat", + "role.deleteDescription": "Peran ini akan dihapus secara permanen dan dihapus dari anggota atau aturan akses mana pun yang menggunakannya.", + "role.deleteTitle": "Hapus \"{{name}}\"?", + "role.deleted": "Peran berhasil dihapus", + "role.duplicated": "Peran berhasil diduplikasi", + "role.groups.builtin": "Peran Sistem", + "role.groups.custom": "Peran Kustom", + "role.loading": "Memuat peran...", + "role.modal.create.description": "Buat peran dan tetapkan izin", + "role.modal.create.title": "Buat Peran", + "role.modal.descriptionLabel": "Deskripsi", + "role.modal.descriptionPlaceholder": "Jelaskan tanggung jawab peran ini", + "role.modal.edit.description": "Edit detail dan izin peran", + "role.modal.edit.title": "Edit Peran", + "role.modal.nameLabel": "Nama peran", + "role.modal.namePlaceholder": "mis. Pemimpin Pemasaran", + "role.modal.view.description": "Lihat detail dan izin peran", + "role.modal.view.title": "Lihat Peran", + "role.noDescription": "Tidak ada deskripsi", + "role.noMatchingRoles": "Tidak ada peran yang cocok", + "role.searchPlaceholder": "Cari peran...", + "role.updated": "Peran berhasil diperbarui", + "role.workspaceRoles.description": "Buat peran dan tentukan apa yang dapat dilakukan setiap peran di ruang kerja ini.", + "role.workspaceRoles.title": "Peran Ruang Kerja" +} diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json new file mode 100644 index 00000000000..5a3c453b135 --- /dev/null +++ b/web/i18n/it-IT/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Gestisci la configurazione delle estensioni API", + "app.access_config": "Configura i permessi di accesso all'app", + "app.acl.access_config": "Configura i permessi di accesso all'app", + "app.acl.delete": "Elimina app", + "app.acl.edit": "Modifica e orchestra l'app", + "app.acl.import_export_dsl": "Importa / esporta DSL", + "app.acl.monitor": "Monitoraggio e operazioni", + "app.acl.preview": "Anteprima app", + "app.acl.release_and_version": "Pubblicazione dell'app e gestione delle versioni", + "app.acl.test_and_run": "Testa e usa l'app", + "app.acl.view_layout": "Pagina di orchestrazione in sola lettura", + "app.create_and_management": "Crea app e gestisci le app che hai creato", + "app.tag.manage": "Gestisci i tag delle app", + "app_library.access": "Accedi alla Libreria delle app", + "billing.manage": "Cambia i piani di abbonamento", + "billing.subscription.manage": "Gestisci fatturazione e abbonamenti nel portale di fatturazione", + "billing.view": "Accedi alle impostazioni di fatturazione", + "credential.create": "Aggiungi credenziali", + "credential.manage": "Modifica ed elimina credenziali", + "credential.use": "Visualizza e usa credenziali", + "customization.manage": "Gestisci personalizzazione", + "data_source.manage": "Gestisci la configurazione delle origini dati", + "dataset.access_config": "Configura i permessi di accesso alla knowledge base", + "dataset.acl.access_config": "Configura i permessi di accesso alla knowledge base", + "dataset.acl.delete": "Elimina knowledge base", + "dataset.acl.delete_file": "Elimina i file della knowledge base", + "dataset.acl.document_download": "Scarica documenti", + "dataset.acl.edit": "Modifica knowledge base", + "dataset.acl.import_export_dsl": "Importa / esporta DSL della knowledge pipeline", + "dataset.acl.pipeline_release": "Pubblicazione della knowledge pipeline e gestione delle versioni", + "dataset.acl.pipeline_test": "Test della pipeline", + "dataset.acl.preview": "Anteprima knowledge base", + "dataset.acl.readonly": "Knowledge base in sola lettura", + "dataset.acl.retrieval_recall": "Recupero dalla knowledge base", + "dataset.acl.use": "Aggiungi documenti alla knowledge base", + "dataset.api_key.manage": "Gestisci le chiavi API della knowledge base", + "dataset.create_and_management": "Crea knowledge base e gestisci le knowledge base che hai creato", + "dataset.external.connect": "Connetti knowledge base esterne", + "dataset.tag.manage": "Gestisci i tag della knowledge base", + "mcp.manage": "Gestisci MCP", + "plugin.debug": "Esegui il debug dei plugin", + "plugin.install": "Installa e aggiorna plugin", + "plugin.manage": "Gestisci plugin", + "plugin.plugin_preferences": "Gestisci le preferenze dei plugin", + "snippets.create_and_modify": "Crea e modifica snippet", + "snippets.management": "Gestisci snippet", + "tool.manage": "Gestisci strumenti", + "workspace.member.manage": "Gestisci i membri", + "workspace.role.manage": "Gestisci i permessi dei ruoli e le regole di accesso alle risorse" +} diff --git a/web/i18n/it-IT/permission.json b/web/i18n/it-IT/permission.json new file mode 100644 index 00000000000..0111207c5dd --- /dev/null +++ b/web/i18n/it-IT/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Azioni", + "accessRule.addMemberAria": "Aggiungi {{name}}", + "accessRule.addMembersTitle": "Aggiungi membri", + "accessRule.allPermittedMembers": "Tutti i membri con permessi di ruolo", + "accessRule.allPermittedMembersDescription": "I membri con permessi di ruolo corrispondenti possono accedere a questa risorsa.", + "accessRule.appDescription": "Controlla a chi è aperta questa app. I membri necessitano comunque dei permessi di ruolo per visualizzarla o utilizzarla.", + "accessRule.appTitle": "Regole di accesso all'app", + "accessRule.changeOpenScopeDescription": "La modifica dell'ambito di apertura reimposterà tutte le impostazioni dei permessi individuali per questa risorsa. Dovrai aggiungere nuovamente i permessi specifici dei membri dopo il cambio.", + "accessRule.changeOpenScopeTitle": "Modificare l'ambito di apertura della risorsa?", + "accessRule.collapseSection": "Comprimi {{title}}", + "accessRule.copied": "Regola di accesso copiata con successo", + "accessRule.created": "Regola di accesso creata con successo", + "accessRule.datasetDescription": "Controlla a chi è aperta questa knowledge base. I membri necessitano comunque dei permessi di ruolo per visualizzarla o utilizzarla.", + "accessRule.datasetTitle": "Regole di accesso alla knowledge base", + "accessRule.defaultPermission": "In base ai permessi di ruolo", + "accessRule.deleteDescription": "Questa regola di accesso verrà eliminata definitivamente e rimossa dall'elenco delle autorizzazioni della risorsa.", + "accessRule.deleteTitle": "Eliminare \"{{name}}\"?", + "accessRule.deleted": "Regola di accesso eliminata con successo", + "accessRule.exceptionPermissionFor": "Permesso di eccezione per {{name}}", + "accessRule.expandSection": "Espandi {{title}}", + "accessRule.individualPermissionSettings": "Impostazioni dei permessi individuali", + "accessRule.individualPermissionSettingsTip": "Imposta eccezioni ai permessi per collaboratori o gruppi specifici. Queste impostazioni hanno la precedenza sul livello di accesso predefinito.", + "accessRule.lockedSummary_one": "· {{count}} bloccato", + "accessRule.lockedSummary_other": "· {{count}} bloccati", + "accessRule.maintainer": "Manutentore", + "accessRule.member": "Membro", + "accessRule.newPermissionSet": "Nuovo set di permessi", + "accessRule.noAvailableMembers": "Nessun membro disponibile da aggiungere", + "accessRule.noDescription": "Nessuna descrizione", + "accessRule.noRoles": "Nessun ruolo", + "accessRule.noRules": "Nessuna regola di accesso", + "accessRule.noUserAccessSettings": "Nessuna impostazione dei permessi individuali", + "accessRule.permission": "Permesso", + "accessRule.resourceOpenScope": "Ambito di apertura della risorsa", + "accessRule.resourceOpenScopeDescription": "Scegli a chi è aperta questa risorsa. I permessi di ruolo determinano comunque cosa può fare ciascun membro.", + "accessRule.specificMembersOnly": "Solo membri specifici", + "accessRule.specificMembersOnlyDescription": "Solo i membri selezionati possono accedere a questa risorsa.", + "accessRule.summary_one": "{{count}} set di permessi", + "accessRule.summary_other": "{{count}} set di permessi", + "accessRule.updated": "Regola di accesso aggiornata con successo", + "common.duplicateAction": "Duplica", + "group.app": "Applicazioni", + "group.app_acl": "Permessi di accesso all'app", + "group.billing": "Fatturazione", + "group.credential": "Credenziali", + "group.dataset": "Knowledge base", + "group.dataset_acl": "Permessi di accesso alla knowledge base", + "group.integration": "Integrazioni", + "group.plugin": "Plugin", + "group.tool_mcp": "Strumenti e MCP", + "group.workspace": "Workspace", + "permissionList.clearAll": "Cancella tutto", + "permissionList.collapseGroup": "Comprimi gruppo", + "permissionList.expandGroup": "Espandi gruppo", + "permissionList.noPermissionsFound": "Nessun permesso trovato", + "permissionList.selectAll": "Seleziona tutto", + "permissionSet.descriptionLabel": "Descrizione", + "permissionSet.descriptionPlaceholder": "Descrivi cosa concede questo set di permessi", + "permissionSet.learnMore": "Scopri di più sui permessi", + "permissionSet.modal.create.app.description": "Crea un set di permessi per app che può essere referenziato nelle regole di accesso per un'autorizzazione rapida.", + "permissionSet.modal.create.app.title": "Crea set di permessi per app", + "permissionSet.modal.create.dataset.description": "Crea un set di permessi per knowledge base che può essere referenziato nelle regole di accesso per un'autorizzazione rapida.", + "permissionSet.modal.create.dataset.title": "Crea set di permessi per knowledge base", + "permissionSet.modal.edit.app.description": "Modifica il nome, la descrizione e i permessi concessi per questo set di permessi.", + "permissionSet.modal.edit.app.title": "Modifica set di permessi per app", + "permissionSet.modal.edit.dataset.description": "Modifica il nome, la descrizione e i permessi concessi per questo set di permessi.", + "permissionSet.modal.edit.dataset.title": "Modifica set di permessi per knowledge base", + "permissionSet.modal.view.app.description": "Visualizza il nome, la descrizione e i permessi concessi per questo set di permessi.", + "permissionSet.modal.view.app.title": "Visualizza set di permessi per app", + "permissionSet.modal.view.dataset.description": "Visualizza il nome, la descrizione e i permessi concessi per questo set di permessi.", + "permissionSet.modal.view.dataset.title": "Visualizza set di permessi per knowledge base", + "permissionSet.nameLabel": "Nome del set di permessi", + "permissionSet.namePlaceholder": "es. Può esportare DSL", + "permissionSet.permissions": "Permessi", + "role.addRole": "Crea ruoli", + "role.copyMembersDescription_one": "\"{{name}}\" è assegnato a {{count}} membro. Vuoi che la copia del nuovo ruolo includa lo stesso membro?", + "role.copyMembersDescription_other": "\"{{name}}\" è assegnato a {{count}} membri. Vuoi che la copia del nuovo ruolo includa gli stessi membri?", + "role.copyMembersLoading": "Caricamento delle assegnazioni dei membri...", + "role.copyMembersTitle": "Copiare le assegnazioni dei membri?", + "role.created": "Ruolo creato con successo", + "role.deleteDescription": "Questo ruolo verrà eliminato definitivamente e rimosso da tutti i membri o le regole di accesso che lo utilizzano.", + "role.deleteTitle": "Eliminare \"{{name}}\"?", + "role.deleted": "Ruolo eliminato con successo", + "role.duplicated": "Ruolo duplicato con successo", + "role.groups.builtin": "Ruoli di sistema", + "role.groups.custom": "Ruoli personalizzati", + "role.loading": "Caricamento dei ruoli...", + "role.modal.create.description": "Crea un ruolo e assegna i permessi", + "role.modal.create.title": "Crea ruolo", + "role.modal.descriptionLabel": "Descrizione", + "role.modal.descriptionPlaceholder": "Descrivi di cosa è responsabile questo ruolo", + "role.modal.edit.description": "Modifica i dettagli e i permessi del ruolo", + "role.modal.edit.title": "Modifica ruolo", + "role.modal.nameLabel": "Nome del ruolo", + "role.modal.namePlaceholder": "es. Responsabile Marketing", + "role.modal.view.description": "Visualizza i dettagli e i permessi del ruolo", + "role.modal.view.title": "Visualizza ruolo", + "role.noDescription": "Nessuna descrizione", + "role.noMatchingRoles": "Nessun ruolo corrispondente", + "role.searchPlaceholder": "Cerca ruoli...", + "role.updated": "Ruolo aggiornato con successo", + "role.workspaceRoles.description": "Crea ruoli e definisci cosa può fare ciascun ruolo in questo workspace.", + "role.workspaceRoles.title": "Ruoli del workspace" +} diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json new file mode 100644 index 00000000000..0a703735b65 --- /dev/null +++ b/web/i18n/ko-KR/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "API 확장 구성 관리", + "app.access_config": "앱 접근 권한 구성", + "app.acl.access_config": "앱 접근 권한 구성", + "app.acl.delete": "앱 삭제", + "app.acl.edit": "앱 편집 및 오케스트레이션", + "app.acl.import_export_dsl": "DSL 가져오기 / 내보내기", + "app.acl.monitor": "모니터링 및 운영", + "app.acl.preview": "앱 미리보기", + "app.acl.release_and_version": "앱 게시 및 버전 관리", + "app.acl.test_and_run": "앱 테스트 및 사용", + "app.acl.view_layout": "읽기 전용 오케스트레이션 페이지", + "app.create_and_management": "앱 생성 및 직접 만든 앱 관리", + "app.tag.manage": "앱 태그 관리", + "app_library.access": "앱 라이브러리 접근", + "billing.manage": "구독 플랜 변경", + "billing.subscription.manage": "결제 포털에서 결제 및 구독 관리", + "billing.view": "결제 설정 접근", + "credential.create": "자격 증명 추가", + "credential.manage": "자격 증명 편집 및 삭제", + "credential.use": "자격 증명 보기 및 사용", + "customization.manage": "사용자 정의 관리", + "data_source.manage": "데이터 소스 구성 관리", + "dataset.access_config": "지식 베이스 접근 권한 구성", + "dataset.acl.access_config": "지식 베이스 접근 권한 구성", + "dataset.acl.delete": "지식 베이스 삭제", + "dataset.acl.delete_file": "지식 베이스 파일 삭제", + "dataset.acl.document_download": "문서 다운로드", + "dataset.acl.edit": "지식 베이스 편집", + "dataset.acl.import_export_dsl": "지식 파이프라인 DSL 가져오기 / 내보내기", + "dataset.acl.pipeline_release": "지식 파이프라인 게시 및 버전 관리", + "dataset.acl.pipeline_test": "파이프라인 테스트", + "dataset.acl.preview": "지식 베이스 미리보기", + "dataset.acl.readonly": "읽기 전용 지식 베이스", + "dataset.acl.retrieval_recall": "지식 베이스 검색", + "dataset.acl.use": "지식 베이스에 문서 추가", + "dataset.api_key.manage": "지식 베이스 API 키 관리", + "dataset.create_and_management": "지식 베이스 생성 및 직접 만든 지식 베이스 관리", + "dataset.external.connect": "외부 지식 베이스 연결", + "dataset.tag.manage": "지식 베이스 태그 관리", + "mcp.manage": "MCP 관리", + "plugin.debug": "플러그인 디버그", + "plugin.install": "플러그인 설치 및 업데이트", + "plugin.manage": "플러그인 관리", + "plugin.plugin_preferences": "플러그인 환경설정 관리", + "snippets.create_and_modify": "스니펫 생성 및 수정", + "snippets.management": "스니펫 관리", + "tool.manage": "도구 관리", + "workspace.member.manage": "멤버 관리", + "workspace.role.manage": "역할 권한 및 리소스 접근 규칙 관리" +} diff --git a/web/i18n/ko-KR/permission.json b/web/i18n/ko-KR/permission.json new file mode 100644 index 00000000000..73f8b48dcfa --- /dev/null +++ b/web/i18n/ko-KR/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "작업", + "accessRule.addMemberAria": "{{name}} 추가", + "accessRule.addMembersTitle": "멤버 추가", + "accessRule.allPermittedMembers": "역할 권한이 있는 모든 멤버", + "accessRule.allPermittedMembersDescription": "일치하는 역할 권한이 있는 멤버가 이 리소스에 접근할 수 있습니다.", + "accessRule.appDescription": "이 앱을 누구에게 공개할지 제어합니다. 멤버가 앱을 보거나 조작하려면 여전히 역할 권한이 필요합니다.", + "accessRule.appTitle": "앱 접근 규칙", + "accessRule.changeOpenScopeDescription": "공개 범위를 변경하면 이 리소스의 모든 개별 권한 설정이 초기화됩니다. 전환 후 멤버별 권한을 다시 추가해야 합니다.", + "accessRule.changeOpenScopeTitle": "리소스 공개 범위를 변경하시겠습니까?", + "accessRule.collapseSection": "{{title}} 접기", + "accessRule.copied": "접근 규칙이 성공적으로 복사되었습니다", + "accessRule.created": "접근 규칙이 성공적으로 생성되었습니다", + "accessRule.datasetDescription": "이 지식 베이스를 누구에게 공개할지 제어합니다. 멤버가 지식 베이스를 보거나 조작하려면 여전히 역할 권한이 필요합니다.", + "accessRule.datasetTitle": "지식 베이스 접근 규칙", + "accessRule.defaultPermission": "역할 권한 기준", + "accessRule.deleteDescription": "이 접근 규칙은 영구적으로 삭제되며 리소스 권한 부여 목록에서 제거됩니다.", + "accessRule.deleteTitle": "\"{{name}}\"을(를) 삭제하시겠습니까?", + "accessRule.deleted": "접근 규칙이 성공적으로 삭제되었습니다", + "accessRule.exceptionPermissionFor": "{{name}}에 대한 예외 권한", + "accessRule.expandSection": "{{title}} 펼치기", + "accessRule.individualPermissionSettings": "개별 권한 설정", + "accessRule.individualPermissionSettingsTip": "특정 협업자 또는 그룹에 대한 권한 예외를 설정합니다. 이 설정은 기본 접근 수준을 재정의합니다.", + "accessRule.lockedSummary_one": "· {{count}}개 잠김", + "accessRule.lockedSummary_other": "· {{count}}개 잠김", + "accessRule.maintainer": "관리자", + "accessRule.member": "멤버", + "accessRule.newPermissionSet": "새 권한 집합", + "accessRule.noAvailableMembers": "추가할 수 있는 멤버가 없습니다", + "accessRule.noDescription": "설명 없음", + "accessRule.noRoles": "역할 없음", + "accessRule.noRules": "접근 규칙 없음", + "accessRule.noUserAccessSettings": "개별 권한 설정 없음", + "accessRule.permission": "권한", + "accessRule.resourceOpenScope": "리소스 공개 범위", + "accessRule.resourceOpenScopeDescription": "이 리소스를 누구에게 공개할지 선택합니다. 각 멤버가 할 수 있는 작업은 여전히 역할 권한이 결정합니다.", + "accessRule.specificMembersOnly": "특정 멤버만", + "accessRule.specificMembersOnlyDescription": "선택한 멤버만 이 리소스에 접근할 수 있습니다.", + "accessRule.summary_one": "{{count}}개 권한 집합", + "accessRule.summary_other": "{{count}}개 권한 집합", + "accessRule.updated": "접근 규칙이 성공적으로 업데이트되었습니다", + "common.duplicateAction": "복제", + "group.app": "애플리케이션", + "group.app_acl": "앱 접근 권한", + "group.billing": "결제", + "group.credential": "자격 증명", + "group.dataset": "지식 베이스", + "group.dataset_acl": "지식 베이스 접근 권한", + "group.integration": "통합", + "group.plugin": "플러그인", + "group.tool_mcp": "도구 및 MCP", + "group.workspace": "작업 공간", + "permissionList.clearAll": "모두 지우기", + "permissionList.collapseGroup": "그룹 접기", + "permissionList.expandGroup": "그룹 펼치기", + "permissionList.noPermissionsFound": "권한을 찾을 수 없습니다", + "permissionList.selectAll": "모두 선택", + "permissionSet.descriptionLabel": "설명", + "permissionSet.descriptionPlaceholder": "이 권한 집합이 부여하는 내용을 설명하세요", + "permissionSet.learnMore": "권한에 대해 자세히 알아보기", + "permissionSet.modal.create.app.description": "빠른 권한 부여를 위해 접근 규칙에서 참조할 수 있는 앱 권한 집합을 생성합니다.", + "permissionSet.modal.create.app.title": "앱 권한 집합 생성", + "permissionSet.modal.create.dataset.description": "빠른 권한 부여를 위해 접근 규칙에서 참조할 수 있는 지식 베이스 권한 집합을 생성합니다.", + "permissionSet.modal.create.dataset.title": "지식 베이스 권한 집합 생성", + "permissionSet.modal.edit.app.description": "이 권한 집합의 이름, 설명 및 부여된 권한을 수정합니다.", + "permissionSet.modal.edit.app.title": "앱 권한 집합 편집", + "permissionSet.modal.edit.dataset.description": "이 권한 집합의 이름, 설명 및 부여된 권한을 수정합니다.", + "permissionSet.modal.edit.dataset.title": "지식 베이스 권한 집합 편집", + "permissionSet.modal.view.app.description": "이 권한 집합의 이름, 설명 및 부여된 권한을 봅니다.", + "permissionSet.modal.view.app.title": "앱 권한 집합 보기", + "permissionSet.modal.view.dataset.description": "이 권한 집합의 이름, 설명 및 부여된 권한을 봅니다.", + "permissionSet.modal.view.dataset.title": "지식 베이스 권한 집합 보기", + "permissionSet.nameLabel": "권한 집합 이름", + "permissionSet.namePlaceholder": "예: DSL 내보내기 가능", + "permissionSet.permissions": "권한", + "role.addRole": "역할 생성", + "role.copyMembersDescription_one": "\"{{name}}\"이(가) {{count}}명의 멤버에게 할당되어 있습니다. 새 역할 복사본에 동일한 멤버를 포함하시겠습니까?", + "role.copyMembersDescription_other": "\"{{name}}\"이(가) {{count}}명의 멤버에게 할당되어 있습니다. 새 역할 복사본에 동일한 멤버를 포함하시겠습니까?", + "role.copyMembersLoading": "멤버 할당을 불러오는 중...", + "role.copyMembersTitle": "멤버 할당을 복사하시겠습니까?", + "role.created": "역할이 성공적으로 생성되었습니다", + "role.deleteDescription": "이 역할은 영구적으로 삭제되며 이를 사용하는 모든 멤버 또는 접근 규칙에서 제거됩니다.", + "role.deleteTitle": "\"{{name}}\"을(를) 삭제하시겠습니까?", + "role.deleted": "역할이 성공적으로 삭제되었습니다", + "role.duplicated": "역할이 성공적으로 복제되었습니다", + "role.groups.builtin": "시스템 역할", + "role.groups.custom": "사용자 정의 역할", + "role.loading": "역할을 불러오는 중...", + "role.modal.create.description": "역할을 생성하고 권한을 할당합니다", + "role.modal.create.title": "역할 생성", + "role.modal.descriptionLabel": "설명", + "role.modal.descriptionPlaceholder": "이 역할이 담당하는 내용을 설명하세요", + "role.modal.edit.description": "역할 세부 정보 및 권한 편집", + "role.modal.edit.title": "역할 편집", + "role.modal.nameLabel": "역할 이름", + "role.modal.namePlaceholder": "예: 마케팅 리드", + "role.modal.view.description": "역할 세부 정보 및 권한 보기", + "role.modal.view.title": "역할 보기", + "role.noDescription": "설명 없음", + "role.noMatchingRoles": "일치하는 역할 없음", + "role.searchPlaceholder": "역할 검색...", + "role.updated": "역할이 성공적으로 업데이트되었습니다", + "role.workspaceRoles.description": "역할을 생성하고 각 역할이 이 작업 공간에서 할 수 있는 작업을 정의합니다.", + "role.workspaceRoles.title": "작업 공간 역할" +} diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json new file mode 100644 index 00000000000..685ac79d194 --- /dev/null +++ b/web/i18n/nl-NL/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "API-extensieconfiguratie beheren", + "app.access_config": "Toegangsrechten voor app configureren", + "app.acl.access_config": "Toegangsrechten voor app configureren", + "app.acl.delete": "App verwijderen", + "app.acl.edit": "App bewerken en orkestreren", + "app.acl.import_export_dsl": "DSL importeren / exporteren", + "app.acl.monitor": "Monitoring en bewerkingen", + "app.acl.preview": "App voorbeeld bekijken", + "app.acl.release_and_version": "App publiceren en versiebeheer", + "app.acl.test_and_run": "App testen en gebruiken", + "app.acl.view_layout": "Alleen-lezen orkestratiepagina", + "app.create_and_management": "Apps maken en door jou gemaakte apps beheren", + "app.tag.manage": "App-tags beheren", + "app_library.access": "Toegang tot App-bibliotheek", + "billing.manage": "Abonnementen wijzigen", + "billing.subscription.manage": "Facturering en abonnementen beheren in het factureringsportaal", + "billing.view": "Toegang tot factureringsinstellingen", + "credential.create": "Inloggegevens toevoegen", + "credential.manage": "Inloggegevens bewerken en verwijderen", + "credential.use": "Inloggegevens bekijken en gebruiken", + "customization.manage": "Aanpassingen beheren", + "data_source.manage": "Configuratie van gegevensbron beheren", + "dataset.access_config": "Toegangsrechten voor kennisbank configureren", + "dataset.acl.access_config": "Toegangsrechten voor kennisbank configureren", + "dataset.acl.delete": "Kennisbank verwijderen", + "dataset.acl.delete_file": "Kennisbankbestanden verwijderen", + "dataset.acl.document_download": "Documenten downloaden", + "dataset.acl.edit": "Kennisbank bewerken", + "dataset.acl.import_export_dsl": "DSL van kennispijplijn importeren / exporteren", + "dataset.acl.pipeline_release": "Kennispijplijn publiceren en versiebeheer", + "dataset.acl.pipeline_test": "Pijplijn testen", + "dataset.acl.preview": "Kennisbank voorbeeld bekijken", + "dataset.acl.readonly": "Alleen-lezen kennisbank", + "dataset.acl.retrieval_recall": "Kennisbank ophalen", + "dataset.acl.use": "Documenten toevoegen aan kennisbank", + "dataset.api_key.manage": "API-sleutels van kennisbank beheren", + "dataset.create_and_management": "Kennisbanken maken en door jou gemaakte kennisbanken beheren", + "dataset.external.connect": "Externe kennisbanken verbinden", + "dataset.tag.manage": "Kennisbank-tags beheren", + "mcp.manage": "MCP beheren", + "plugin.debug": "Plug-ins debuggen", + "plugin.install": "Plug-ins installeren en bijwerken", + "plugin.manage": "Plug-ins beheren", + "plugin.plugin_preferences": "Plug-invoorkeuren beheren", + "snippets.create_and_modify": "Snippets maken en wijzigen", + "snippets.management": "Snippets beheren", + "tool.manage": "Tools beheren", + "workspace.member.manage": "Leden beheren", + "workspace.role.manage": "Rolrechten en regels voor resourcetoegang beheren" +} diff --git a/web/i18n/nl-NL/permission.json b/web/i18n/nl-NL/permission.json new file mode 100644 index 00000000000..44f8ec4384c --- /dev/null +++ b/web/i18n/nl-NL/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Acties", + "accessRule.addMemberAria": "{{name}} toevoegen", + "accessRule.addMembersTitle": "Leden toevoegen", + "accessRule.allPermittedMembers": "Alle leden met rolrechten", + "accessRule.allPermittedMembersDescription": "Leden met overeenkomende rolrechten hebben toegang tot deze resource.", + "accessRule.appDescription": "Bepaal voor wie deze app toegankelijk is. Leden hebben nog steeds rolrechten nodig om deze te bekijken of te bedienen.", + "accessRule.appTitle": "Toegangsregels voor app", + "accessRule.changeOpenScopeDescription": "Het wijzigen van het toegangsbereik stelt alle individuele rechteninstellingen voor deze resource opnieuw in. Je moet ledenspecifieke rechten opnieuw toevoegen na het wisselen.", + "accessRule.changeOpenScopeTitle": "Toegangsbereik van resource wijzigen?", + "accessRule.collapseSection": "{{title}} samenvouwen", + "accessRule.copied": "Toegangsregel succesvol gekopieerd", + "accessRule.created": "Toegangsregel succesvol gemaakt", + "accessRule.datasetDescription": "Bepaal voor wie deze kennisbank toegankelijk is. Leden hebben nog steeds rolrechten nodig om deze te bekijken of te bedienen.", + "accessRule.datasetTitle": "Toegangsregels voor kennisbank", + "accessRule.defaultPermission": "Op basis van rolrechten", + "accessRule.deleteDescription": "Deze toegangsregel wordt permanent verwijderd en uit de autorisatielijst van de resource gehaald.", + "accessRule.deleteTitle": "\"{{name}}\" verwijderen?", + "accessRule.deleted": "Toegangsregel succesvol verwijderd", + "accessRule.exceptionPermissionFor": "Uitzonderingsrecht voor {{name}}", + "accessRule.expandSection": "{{title}} uitvouwen", + "accessRule.individualPermissionSettings": "Individuele rechteninstellingen", + "accessRule.individualPermissionSettingsTip": "Stel rechtenuitzonderingen in voor specifieke samenwerkers of groepen. Deze instellingen overschrijven het standaard toegangsniveau.", + "accessRule.lockedSummary_one": "· {{count}} vergrendeld", + "accessRule.lockedSummary_other": "· {{count}} vergrendeld", + "accessRule.maintainer": "Beheerder", + "accessRule.member": "Lid", + "accessRule.newPermissionSet": "Nieuwe rechtenset", + "accessRule.noAvailableMembers": "Geen leden beschikbaar om toe te voegen", + "accessRule.noDescription": "Geen beschrijving", + "accessRule.noRoles": "Geen rollen", + "accessRule.noRules": "Geen toegangsregels", + "accessRule.noUserAccessSettings": "Geen individuele rechteninstellingen", + "accessRule.permission": "Recht", + "accessRule.resourceOpenScope": "Toegangsbereik van resource", + "accessRule.resourceOpenScopeDescription": "Kies voor wie deze resource toegankelijk is. Rolrechten bepalen nog steeds wat elk lid kan doen.", + "accessRule.specificMembersOnly": "Alleen specifieke leden", + "accessRule.specificMembersOnlyDescription": "Alleen geselecteerde leden hebben toegang tot deze resource.", + "accessRule.summary_one": "{{count}} rechtenset", + "accessRule.summary_other": "{{count}} rechtensets", + "accessRule.updated": "Toegangsregel succesvol bijgewerkt", + "common.duplicateAction": "Dupliceren", + "group.app": "Applicaties", + "group.app_acl": "Toegangsrechten voor app", + "group.billing": "Facturering", + "group.credential": "Inloggegevens", + "group.dataset": "Kennisbanken", + "group.dataset_acl": "Toegangsrechten voor kennisbank", + "group.integration": "Integraties", + "group.plugin": "Plug-ins", + "group.tool_mcp": "Tools en MCP", + "group.workspace": "Werkruimte", + "permissionList.clearAll": "Alles wissen", + "permissionList.collapseGroup": "Groep samenvouwen", + "permissionList.expandGroup": "Groep uitvouwen", + "permissionList.noPermissionsFound": "Geen rechten gevonden", + "permissionList.selectAll": "Alles selecteren", + "permissionSet.descriptionLabel": "Beschrijving", + "permissionSet.descriptionPlaceholder": "Beschrijf wat deze rechtenset verleent", + "permissionSet.learnMore": "Meer informatie over rechten", + "permissionSet.modal.create.app.description": "Maak een app-rechtenset die in toegangsregels kan worden gebruikt voor snelle autorisatie.", + "permissionSet.modal.create.app.title": "App-rechtenset maken", + "permissionSet.modal.create.dataset.description": "Maak een kennisbank-rechtenset die in toegangsregels kan worden gebruikt voor snelle autorisatie.", + "permissionSet.modal.create.dataset.title": "Kennisbank-rechtenset maken", + "permissionSet.modal.edit.app.description": "Wijzig de naam, beschrijving en verleende rechten voor deze rechtenset.", + "permissionSet.modal.edit.app.title": "App-rechtenset bewerken", + "permissionSet.modal.edit.dataset.description": "Wijzig de naam, beschrijving en verleende rechten voor deze rechtenset.", + "permissionSet.modal.edit.dataset.title": "Kennisbank-rechtenset bewerken", + "permissionSet.modal.view.app.description": "Bekijk de naam, beschrijving en verleende rechten voor deze rechtenset.", + "permissionSet.modal.view.app.title": "App-rechtenset bekijken", + "permissionSet.modal.view.dataset.description": "Bekijk de naam, beschrijving en verleende rechten voor deze rechtenset.", + "permissionSet.modal.view.dataset.title": "Kennisbank-rechtenset bekijken", + "permissionSet.nameLabel": "Naam van rechtenset", + "permissionSet.namePlaceholder": "bijv. Kan DSL exporteren", + "permissionSet.permissions": "Rechten", + "role.addRole": "Rollen maken", + "role.copyMembersDescription_one": "\"{{name}}\" is toegewezen aan {{count}} lid. Wil je dat de nieuwe rolkopie hetzelfde lid bevat?", + "role.copyMembersDescription_other": "\"{{name}}\" is toegewezen aan {{count}} leden. Wil je dat de nieuwe rolkopie dezelfde leden bevat?", + "role.copyMembersLoading": "Ledentoewijzingen laden...", + "role.copyMembersTitle": "Ledentoewijzingen kopiëren?", + "role.created": "Rol succesvol gemaakt", + "role.deleteDescription": "Deze rol wordt permanent verwijderd en uit alle leden of toegangsregels die deze gebruiken gehaald.", + "role.deleteTitle": "\"{{name}}\" verwijderen?", + "role.deleted": "Rol succesvol verwijderd", + "role.duplicated": "Rol succesvol gedupliceerd", + "role.groups.builtin": "Systeemrollen", + "role.groups.custom": "Aangepaste rollen", + "role.loading": "Rollen laden...", + "role.modal.create.description": "Maak een rol en wijs rechten toe", + "role.modal.create.title": "Rol maken", + "role.modal.descriptionLabel": "Beschrijving", + "role.modal.descriptionPlaceholder": "Beschrijf waar deze rol verantwoordelijk voor is", + "role.modal.edit.description": "Roldetails en rechten bewerken", + "role.modal.edit.title": "Rol bewerken", + "role.modal.nameLabel": "Rolnaam", + "role.modal.namePlaceholder": "bijv. Marketingleider", + "role.modal.view.description": "Roldetails en rechten bekijken", + "role.modal.view.title": "Rol bekijken", + "role.noDescription": "Geen beschrijving", + "role.noMatchingRoles": "Geen overeenkomende rollen", + "role.searchPlaceholder": "Rollen zoeken...", + "role.updated": "Rol succesvol bijgewerkt", + "role.workspaceRoles.description": "Maak rollen en bepaal wat elke rol in deze werkruimte kan doen.", + "role.workspaceRoles.title": "Werkruimterollen" +} diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json new file mode 100644 index 00000000000..7b151edac91 --- /dev/null +++ b/web/i18n/pl-PL/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API", + "app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", + "app.acl.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", + "app.acl.delete": "Usuń aplikację", + "app.acl.edit": "Edytuj i orkiestruj aplikację", + "app.acl.import_export_dsl": "Importuj / eksportuj DSL", + "app.acl.monitor": "Monitorowanie i operacje", + "app.acl.preview": "Podgląd aplikacji", + "app.acl.release_and_version": "Publikowanie aplikacji i zarządzanie wersjami", + "app.acl.test_and_run": "Testuj i używaj aplikacji", + "app.acl.view_layout": "Strona orkiestracji tylko do odczytu", + "app.create_and_management": "Twórz aplikacje i zarządzaj utworzonymi przez siebie aplikacjami", + "app.tag.manage": "Zarządzaj tagami aplikacji", + "app_library.access": "Dostęp do Biblioteki aplikacji", + "billing.manage": "Zmień plany subskrypcji", + "billing.subscription.manage": "Zarządzaj rozliczeniami i subskrypcjami w portalu rozliczeniowym", + "billing.view": "Dostęp do ustawień rozliczeń", + "credential.create": "Dodaj poświadczenia", + "credential.manage": "Edytuj i usuwaj poświadczenia", + "credential.use": "Przeglądaj i używaj poświadczeń", + "customization.manage": "Zarządzaj dostosowywaniem", + "data_source.manage": "Zarządzaj konfiguracją źródła danych", + "dataset.access_config": "Konfiguruj uprawnienia dostępu do bazy wiedzy", + "dataset.acl.access_config": "Konfiguruj uprawnienia dostępu do bazy wiedzy", + "dataset.acl.delete": "Usuń bazę wiedzy", + "dataset.acl.delete_file": "Usuń pliki bazy wiedzy", + "dataset.acl.document_download": "Pobierz dokumenty", + "dataset.acl.edit": "Edytuj bazę wiedzy", + "dataset.acl.import_export_dsl": "Importuj / eksportuj DSL potoku wiedzy", + "dataset.acl.pipeline_release": "Publikowanie potoku wiedzy i zarządzanie wersjami", + "dataset.acl.pipeline_test": "Testowanie potoku", + "dataset.acl.preview": "Podgląd bazy wiedzy", + "dataset.acl.readonly": "Baza wiedzy tylko do odczytu", + "dataset.acl.retrieval_recall": "Wyszukiwanie w bazie wiedzy", + "dataset.acl.use": "Dodaj dokumenty do bazy wiedzy", + "dataset.api_key.manage": "Zarządzaj kluczami API bazy wiedzy", + "dataset.create_and_management": "Twórz bazy wiedzy i zarządzaj utworzonymi przez siebie bazami wiedzy", + "dataset.external.connect": "Połącz zewnętrzne bazy wiedzy", + "dataset.tag.manage": "Zarządzaj tagami bazy wiedzy", + "mcp.manage": "Zarządzaj MCP", + "plugin.debug": "Debuguj wtyczki", + "plugin.install": "Instaluj i aktualizuj wtyczki", + "plugin.manage": "Zarządzaj wtyczkami", + "plugin.plugin_preferences": "Zarządzaj preferencjami wtyczek", + "snippets.create_and_modify": "Twórz i modyfikuj fragmenty kodu", + "snippets.management": "Zarządzaj fragmentami kodu", + "tool.manage": "Zarządzaj narzędziami", + "workspace.member.manage": "Zarządzaj członkami", + "workspace.role.manage": "Zarządzaj uprawnieniami ról i regułami dostępu do zasobów" +} diff --git a/web/i18n/pl-PL/permission.json b/web/i18n/pl-PL/permission.json new file mode 100644 index 00000000000..aea644c8133 --- /dev/null +++ b/web/i18n/pl-PL/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Akcje", + "accessRule.addMemberAria": "Dodaj {{name}}", + "accessRule.addMembersTitle": "Dodaj członków", + "accessRule.allPermittedMembers": "Wszyscy członkowie z uprawnieniami ról", + "accessRule.allPermittedMembersDescription": "Członkowie z pasującymi uprawnieniami ról mogą uzyskać dostęp do tego zasobu.", + "accessRule.appDescription": "Kontroluj, dla kogo ta aplikacja jest otwarta. Członkowie nadal potrzebują uprawnień ról, aby ją przeglądać lub obsługiwać.", + "accessRule.appTitle": "Reguły dostępu do aplikacji", + "accessRule.changeOpenScopeDescription": "Zmiana zakresu otwarcia zresetuje wszystkie indywidualne ustawienia uprawnień dla tego zasobu. Po zmianie będziesz musiał ponownie dodać uprawnienia specyficzne dla członków.", + "accessRule.changeOpenScopeTitle": "Zmienić zakres otwarcia zasobu?", + "accessRule.collapseSection": "Zwiń {{title}}", + "accessRule.copied": "Pomyślnie skopiowano regułę dostępu", + "accessRule.created": "Pomyślnie utworzono regułę dostępu", + "accessRule.datasetDescription": "Kontroluj, dla kogo ta baza wiedzy jest otwarta. Członkowie nadal potrzebują uprawnień ról, aby ją przeglądać lub obsługiwać.", + "accessRule.datasetTitle": "Reguły dostępu do bazy wiedzy", + "accessRule.defaultPermission": "Według uprawnień ról", + "accessRule.deleteDescription": "Ta reguła dostępu zostanie trwale usunięta i wykreślona z listy autoryzacji zasobu.", + "accessRule.deleteTitle": "Usunąć \"{{name}}\"?", + "accessRule.deleted": "Pomyślnie usunięto regułę dostępu", + "accessRule.exceptionPermissionFor": "Wyjątkowe uprawnienie dla {{name}}", + "accessRule.expandSection": "Rozwiń {{title}}", + "accessRule.individualPermissionSettings": "Indywidualne ustawienia uprawnień", + "accessRule.individualPermissionSettingsTip": "Ustaw wyjątki uprawnień dla określonych współpracowników lub grup. Te ustawienia zastępują domyślny poziom dostępu.", + "accessRule.lockedSummary_one": "· {{count}} zablokowany", + "accessRule.lockedSummary_other": "· {{count}} zablokowanych", + "accessRule.maintainer": "Opiekun", + "accessRule.member": "Członek", + "accessRule.newPermissionSet": "Nowy zestaw uprawnień", + "accessRule.noAvailableMembers": "Brak członków dostępnych do dodania", + "accessRule.noDescription": "Brak opisu", + "accessRule.noRoles": "Brak ról", + "accessRule.noRules": "Brak reguł dostępu", + "accessRule.noUserAccessSettings": "Brak indywidualnych ustawień uprawnień", + "accessRule.permission": "Uprawnienie", + "accessRule.resourceOpenScope": "Zakres otwarcia zasobu", + "accessRule.resourceOpenScopeDescription": "Wybierz, dla kogo ten zasób jest otwarty. Uprawnienia ról nadal decydują o tym, co każdy członek może robić.", + "accessRule.specificMembersOnly": "Tylko określeni członkowie", + "accessRule.specificMembersOnlyDescription": "Tylko wybrani członkowie mogą uzyskać dostęp do tego zasobu.", + "accessRule.summary_one": "{{count}} zestaw uprawnień", + "accessRule.summary_other": "{{count}} zestawów uprawnień", + "accessRule.updated": "Pomyślnie zaktualizowano regułę dostępu", + "common.duplicateAction": "Duplikuj", + "group.app": "Aplikacje", + "group.app_acl": "Uprawnienia dostępu do aplikacji", + "group.billing": "Rozliczenia", + "group.credential": "Poświadczenia", + "group.dataset": "Bazy wiedzy", + "group.dataset_acl": "Uprawnienia dostępu do bazy wiedzy", + "group.integration": "Integracje", + "group.plugin": "Wtyczki", + "group.tool_mcp": "Narzędzia i MCP", + "group.workspace": "Przestrzeń robocza", + "permissionList.clearAll": "Wyczyść wszystko", + "permissionList.collapseGroup": "Zwiń grupę", + "permissionList.expandGroup": "Rozwiń grupę", + "permissionList.noPermissionsFound": "Nie znaleziono uprawnień", + "permissionList.selectAll": "Zaznacz wszystko", + "permissionSet.descriptionLabel": "Opis", + "permissionSet.descriptionPlaceholder": "Opisz, co przyznaje ten zestaw uprawnień", + "permissionSet.learnMore": "Dowiedz się więcej o uprawnieniach", + "permissionSet.modal.create.app.description": "Utwórz zestaw uprawnień aplikacji, do którego można się odwoływać w regułach dostępu w celu szybkiej autoryzacji.", + "permissionSet.modal.create.app.title": "Utwórz zestaw uprawnień aplikacji", + "permissionSet.modal.create.dataset.description": "Utwórz zestaw uprawnień bazy wiedzy, do którego można się odwoływać w regułach dostępu w celu szybkiej autoryzacji.", + "permissionSet.modal.create.dataset.title": "Utwórz zestaw uprawnień bazy wiedzy", + "permissionSet.modal.edit.app.description": "Zmodyfikuj nazwę, opis i uprawnienia przyznane dla tego zestawu uprawnień.", + "permissionSet.modal.edit.app.title": "Edytuj zestaw uprawnień aplikacji", + "permissionSet.modal.edit.dataset.description": "Zmodyfikuj nazwę, opis i uprawnienia przyznane dla tego zestawu uprawnień.", + "permissionSet.modal.edit.dataset.title": "Edytuj zestaw uprawnień bazy wiedzy", + "permissionSet.modal.view.app.description": "Wyświetl nazwę, opis i uprawnienia przyznane dla tego zestawu uprawnień.", + "permissionSet.modal.view.app.title": "Wyświetl zestaw uprawnień aplikacji", + "permissionSet.modal.view.dataset.description": "Wyświetl nazwę, opis i uprawnienia przyznane dla tego zestawu uprawnień.", + "permissionSet.modal.view.dataset.title": "Wyświetl zestaw uprawnień bazy wiedzy", + "permissionSet.nameLabel": "Nazwa zestawu uprawnień", + "permissionSet.namePlaceholder": "np. Może eksportować DSL", + "permissionSet.permissions": "Uprawnienia", + "role.addRole": "Twórz role", + "role.copyMembersDescription_one": "\"{{name}}\" jest przypisana do {{count}} członka. Czy chcesz, aby nowa kopia roli zawierała tego samego członka?", + "role.copyMembersDescription_other": "\"{{name}}\" jest przypisana do {{count}} członków. Czy chcesz, aby nowa kopia roli zawierała tych samych członków?", + "role.copyMembersLoading": "Ładowanie przypisań członków...", + "role.copyMembersTitle": "Skopiować przypisania członków?", + "role.created": "Pomyślnie utworzono rolę", + "role.deleteDescription": "Ta rola zostanie trwale usunięta i wykreślona ze wszystkich członków lub reguł dostępu, które jej używają.", + "role.deleteTitle": "Usunąć \"{{name}}\"?", + "role.deleted": "Pomyślnie usunięto rolę", + "role.duplicated": "Pomyślnie zduplikowano rolę", + "role.groups.builtin": "Role systemowe", + "role.groups.custom": "Role niestandardowe", + "role.loading": "Ładowanie ról...", + "role.modal.create.description": "Utwórz rolę i przypisz uprawnienia", + "role.modal.create.title": "Utwórz rolę", + "role.modal.descriptionLabel": "Opis", + "role.modal.descriptionPlaceholder": "Opisz, za co odpowiada ta rola", + "role.modal.edit.description": "Edytuj szczegóły i uprawnienia roli", + "role.modal.edit.title": "Edytuj rolę", + "role.modal.nameLabel": "Nazwa roli", + "role.modal.namePlaceholder": "np. Lider marketingu", + "role.modal.view.description": "Wyświetl szczegóły i uprawnienia roli", + "role.modal.view.title": "Wyświetl rolę", + "role.noDescription": "Brak opisu", + "role.noMatchingRoles": "Brak pasujących ról", + "role.searchPlaceholder": "Szukaj ról...", + "role.updated": "Pomyślnie zaktualizowano rolę", + "role.workspaceRoles.description": "Twórz role i definiuj, co każda rola może robić w tej przestrzeni roboczej.", + "role.workspaceRoles.title": "Role przestrzeni roboczej" +} diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json new file mode 100644 index 00000000000..2b23db09971 --- /dev/null +++ b/web/i18n/pt-BR/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Gerenciar configuração de extensão de API", + "app.access_config": "Configurar permissões de acesso ao aplicativo", + "app.acl.access_config": "Configurar permissões de acesso ao aplicativo", + "app.acl.delete": "Excluir aplicativo", + "app.acl.edit": "Editar e orquestrar aplicativo", + "app.acl.import_export_dsl": "Importar / exportar DSL", + "app.acl.monitor": "Monitoramento e operações", + "app.acl.preview": "Visualizar aplicativo", + "app.acl.release_and_version": "Publicação de aplicativo e gerenciamento de versões", + "app.acl.test_and_run": "Testar e usar aplicativo", + "app.acl.view_layout": "Página de orquestração somente leitura", + "app.create_and_management": "Criar aplicativos e gerenciar os aplicativos que você criou", + "app.tag.manage": "Gerenciar tags de aplicativo", + "app_library.access": "Acessar a Biblioteca de Aplicativos", + "billing.manage": "Alterar planos de assinatura", + "billing.subscription.manage": "Gerenciar faturamento e assinaturas no portal de faturamento", + "billing.view": "Acessar configurações de Faturamento", + "credential.create": "Adicionar credenciais", + "credential.manage": "Editar e excluir credenciais", + "credential.use": "Visualizar e usar credenciais", + "customization.manage": "Gerenciar personalização", + "data_source.manage": "Gerenciar configuração de fonte de dados", + "dataset.access_config": "Configurar permissões de acesso ao Conhecimento", + "dataset.acl.access_config": "Configurar permissões de acesso ao Conhecimento", + "dataset.acl.delete": "Excluir Conhecimento", + "dataset.acl.delete_file": "Excluir arquivos do Conhecimento", + "dataset.acl.document_download": "Baixar documentos", + "dataset.acl.edit": "Editar Conhecimento", + "dataset.acl.import_export_dsl": "Importar / exportar DSL de pipeline de conhecimento", + "dataset.acl.pipeline_release": "Publicação de pipeline de conhecimento e gerenciamento de versões", + "dataset.acl.pipeline_test": "Teste de pipeline", + "dataset.acl.preview": "Visualizar Conhecimento", + "dataset.acl.readonly": "Conhecimento somente leitura", + "dataset.acl.retrieval_recall": "Recuperação do Conhecimento", + "dataset.acl.use": "Adicionar documentos ao Conhecimento", + "dataset.api_key.manage": "Gerenciar chaves de API do Conhecimento", + "dataset.create_and_management": "Criar Conhecimentos e gerenciar os Conhecimentos que você criou", + "dataset.external.connect": "Conectar Conhecimentos externos", + "dataset.tag.manage": "Gerenciar tags do Conhecimento", + "mcp.manage": "Gerenciar MCP", + "plugin.debug": "Depurar plug-ins", + "plugin.install": "Instalar e atualizar plug-ins", + "plugin.manage": "Gerenciar plug-ins", + "plugin.plugin_preferences": "Gerenciar preferências de plug-ins", + "snippets.create_and_modify": "Criar e modificar snippets", + "snippets.management": "Gerenciar snippets", + "tool.manage": "Gerenciar ferramentas", + "workspace.member.manage": "Gerenciar membros", + "workspace.role.manage": "Gerenciar permissões de função e regras de acesso a recursos" +} diff --git a/web/i18n/pt-BR/permission.json b/web/i18n/pt-BR/permission.json new file mode 100644 index 00000000000..3b403cc5bd7 --- /dev/null +++ b/web/i18n/pt-BR/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Ações", + "accessRule.addMemberAria": "Adicionar {{name}}", + "accessRule.addMembersTitle": "Adicionar membros", + "accessRule.allPermittedMembers": "Todos os membros com permissões de função", + "accessRule.allPermittedMembersDescription": "Membros com permissões de função correspondentes podem acessar este recurso.", + "accessRule.appDescription": "Controle para quem este aplicativo está aberto. Os membros ainda precisam de permissões de função para visualizá-lo ou operá-lo.", + "accessRule.appTitle": "Regras de Acesso ao Aplicativo", + "accessRule.changeOpenScopeDescription": "Alterar o escopo de abertura redefinirá todas as configurações de permissão individuais para este recurso. Você precisará adicionar permissões específicas de membro novamente após a alteração.", + "accessRule.changeOpenScopeTitle": "Alterar escopo de abertura do recurso?", + "accessRule.collapseSection": "Recolher {{title}}", + "accessRule.copied": "Regra de acesso copiada com sucesso", + "accessRule.created": "Regra de acesso criada com sucesso", + "accessRule.datasetDescription": "Controle para quem este Conhecimento está aberto. Os membros ainda precisam de permissões de função para visualizá-lo ou operá-lo.", + "accessRule.datasetTitle": "Regras de Acesso ao Conhecimento", + "accessRule.defaultPermission": "Por permissões de função", + "accessRule.deleteDescription": "Esta regra de acesso será excluída permanentemente e removida da lista de autorização do recurso.", + "accessRule.deleteTitle": "Excluir \"{{name}}\"?", + "accessRule.deleted": "Regra de acesso excluída com sucesso", + "accessRule.exceptionPermissionFor": "Permissão de exceção para {{name}}", + "accessRule.expandSection": "Expandir {{title}}", + "accessRule.individualPermissionSettings": "Configurações de permissão individuais", + "accessRule.individualPermissionSettingsTip": "Defina exceções de permissão para colaboradores ou grupos específicos. Essas configurações substituem o nível de acesso padrão.", + "accessRule.lockedSummary_one": "· {{count}} bloqueado", + "accessRule.lockedSummary_other": "· {{count}} bloqueados", + "accessRule.maintainer": "Mantenedor", + "accessRule.member": "Membro", + "accessRule.newPermissionSet": "Novo conjunto de permissões", + "accessRule.noAvailableMembers": "Nenhum membro disponível para adicionar", + "accessRule.noDescription": "Sem descrição", + "accessRule.noRoles": "Sem funções", + "accessRule.noRules": "Sem regras de acesso", + "accessRule.noUserAccessSettings": "Sem configurações de permissão individuais", + "accessRule.permission": "Permissão", + "accessRule.resourceOpenScope": "Escopo de abertura do recurso", + "accessRule.resourceOpenScopeDescription": "Escolha para quem este recurso está aberto. As permissões de função ainda determinam o que cada membro pode fazer.", + "accessRule.specificMembersOnly": "Apenas membros específicos", + "accessRule.specificMembersOnlyDescription": "Apenas os membros selecionados podem acessar este recurso.", + "accessRule.summary_one": "{{count}} conjunto de permissões", + "accessRule.summary_other": "{{count}} conjuntos de permissões", + "accessRule.updated": "Regra de acesso atualizada com sucesso", + "common.duplicateAction": "Duplicar", + "group.app": "Aplicativos", + "group.app_acl": "Permissões de acesso ao aplicativo", + "group.billing": "Faturamento", + "group.credential": "Credenciais", + "group.dataset": "Conhecimentos", + "group.dataset_acl": "Permissões de acesso ao Conhecimento", + "group.integration": "Integrações", + "group.plugin": "Plug-ins", + "group.tool_mcp": "Ferramentas e MCP", + "group.workspace": "Espaço de trabalho", + "permissionList.clearAll": "Limpar tudo", + "permissionList.collapseGroup": "Recolher grupo", + "permissionList.expandGroup": "Expandir grupo", + "permissionList.noPermissionsFound": "Nenhuma permissão encontrada", + "permissionList.selectAll": "Selecionar tudo", + "permissionSet.descriptionLabel": "Descrição", + "permissionSet.descriptionPlaceholder": "Descreva o que este conjunto de permissões concede", + "permissionSet.learnMore": "Saiba mais sobre permissões", + "permissionSet.modal.create.app.description": "Crie um conjunto de permissões de aplicativo que pode ser referenciado em regras de acesso para autorização rápida.", + "permissionSet.modal.create.app.title": "Criar conjunto de permissões de Aplicativo", + "permissionSet.modal.create.dataset.description": "Crie um conjunto de permissões de Conhecimento que pode ser referenciado em regras de acesso para autorização rápida.", + "permissionSet.modal.create.dataset.title": "Criar conjunto de permissões de Conhecimento", + "permissionSet.modal.edit.app.description": "Modifique o nome, a descrição e as permissões concedidas para este conjunto de permissões.", + "permissionSet.modal.edit.app.title": "Editar conjunto de permissões de Aplicativo", + "permissionSet.modal.edit.dataset.description": "Modifique o nome, a descrição e as permissões concedidas para este conjunto de permissões.", + "permissionSet.modal.edit.dataset.title": "Editar conjunto de permissões de Conhecimento", + "permissionSet.modal.view.app.description": "Visualize o nome, a descrição e as permissões concedidas para este conjunto de permissões.", + "permissionSet.modal.view.app.title": "Visualizar conjunto de permissões de Aplicativo", + "permissionSet.modal.view.dataset.description": "Visualize o nome, a descrição e as permissões concedidas para este conjunto de permissões.", + "permissionSet.modal.view.dataset.title": "Visualizar conjunto de permissões de Conhecimento", + "permissionSet.nameLabel": "Nome do conjunto de permissões", + "permissionSet.namePlaceholder": "ex.: Pode exportar DSL", + "permissionSet.permissions": "Permissões", + "role.addRole": "Criar funções", + "role.copyMembersDescription_one": "\"{{name}}\" está atribuída a {{count}} membro. Você deseja que a cópia da nova função inclua o mesmo membro?", + "role.copyMembersDescription_other": "\"{{name}}\" está atribuída a {{count}} membros. Você deseja que a cópia da nova função inclua os mesmos membros?", + "role.copyMembersLoading": "Carregando atribuições de membros...", + "role.copyMembersTitle": "Copiar atribuições de membros?", + "role.created": "Função criada com sucesso", + "role.deleteDescription": "Esta função será excluída permanentemente e removida de quaisquer membros ou regras de acesso que a utilizem.", + "role.deleteTitle": "Excluir \"{{name}}\"?", + "role.deleted": "Função excluída com sucesso", + "role.duplicated": "Função duplicada com sucesso", + "role.groups.builtin": "Funções do Sistema", + "role.groups.custom": "Funções Personalizadas", + "role.loading": "Carregando funções...", + "role.modal.create.description": "Crie uma função e atribua permissões", + "role.modal.create.title": "Criar Função", + "role.modal.descriptionLabel": "Descrição", + "role.modal.descriptionPlaceholder": "Descreva pelo que esta função é responsável", + "role.modal.edit.description": "Editar detalhes e permissões da função", + "role.modal.edit.title": "Editar Função", + "role.modal.nameLabel": "Nome da função", + "role.modal.namePlaceholder": "ex.: Líder de Marketing", + "role.modal.view.description": "Visualizar detalhes e permissões da função", + "role.modal.view.title": "Visualizar Função", + "role.noDescription": "Sem descrição", + "role.noMatchingRoles": "Nenhuma função correspondente", + "role.searchPlaceholder": "Pesquisar funções...", + "role.updated": "Função atualizada com sucesso", + "role.workspaceRoles.description": "Crie funções e defina o que cada função pode fazer neste espaço de trabalho.", + "role.workspaceRoles.title": "Funções do Espaço de Trabalho" +} diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json new file mode 100644 index 00000000000..1d7de2be2a3 --- /dev/null +++ b/web/i18n/ro-RO/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Gestionează configurația extensiei API", + "app.access_config": "Configurează permisiunile de acces ale aplicației", + "app.acl.access_config": "Configurează permisiunile de acces ale aplicației", + "app.acl.delete": "Șterge aplicația", + "app.acl.edit": "Editează și orchestrează aplicația", + "app.acl.import_export_dsl": "Importă / exportă DSL", + "app.acl.monitor": "Monitorizare și operațiuni", + "app.acl.preview": "Previzualizează aplicația", + "app.acl.release_and_version": "Publicarea aplicației și gestionarea versiunilor", + "app.acl.test_and_run": "Testează și utilizează aplicația", + "app.acl.view_layout": "Pagină de orchestrare doar pentru citire", + "app.create_and_management": "Creează aplicații și gestionează aplicațiile pe care le-ai creat", + "app.tag.manage": "Gestionează etichetele aplicațiilor", + "app_library.access": "Accesează Biblioteca de aplicații", + "billing.manage": "Schimbă planurile de abonament", + "billing.subscription.manage": "Gestionează facturarea și abonamentele în portalul de facturare", + "billing.view": "Accesează setările de Facturare", + "credential.create": "Adaugă acreditive", + "credential.manage": "Editează și șterge acreditive", + "credential.use": "Vizualizează și utilizează acreditive", + "customization.manage": "Gestionează personalizarea", + "data_source.manage": "Gestionează configurația sursei de date", + "dataset.access_config": "Configurează permisiunile de acces ale bazei de cunoștințe", + "dataset.acl.access_config": "Configurează permisiunile de acces ale bazei de cunoștințe", + "dataset.acl.delete": "Șterge baza de cunoștințe", + "dataset.acl.delete_file": "Șterge fișierele bazei de cunoștințe", + "dataset.acl.document_download": "Descarcă documente", + "dataset.acl.edit": "Editează baza de cunoștințe", + "dataset.acl.import_export_dsl": "Importă / exportă DSL-ul pipeline-ului de cunoștințe", + "dataset.acl.pipeline_release": "Publicarea pipeline-ului de cunoștințe și gestionarea versiunilor", + "dataset.acl.pipeline_test": "Testarea pipeline-ului", + "dataset.acl.preview": "Previzualizează baza de cunoștințe", + "dataset.acl.readonly": "Bază de cunoștințe doar pentru citire", + "dataset.acl.retrieval_recall": "Recuperare din baza de cunoștințe", + "dataset.acl.use": "Adaugă documente în baza de cunoștințe", + "dataset.api_key.manage": "Gestionează cheile API ale bazei de cunoștințe", + "dataset.create_and_management": "Creează baze de cunoștințe și gestionează bazele de cunoștințe pe care le-ai creat", + "dataset.external.connect": "Conectează baze de cunoștințe externe", + "dataset.tag.manage": "Gestionează etichetele bazei de cunoștințe", + "mcp.manage": "Gestionează MCP", + "plugin.debug": "Depanează plugin-uri", + "plugin.install": "Instalează și actualizează plugin-uri", + "plugin.manage": "Gestionează plugin-uri", + "plugin.plugin_preferences": "Gestionează preferințele plugin-urilor", + "snippets.create_and_modify": "Creează și modifică fragmente", + "snippets.management": "Gestionează fragmente", + "tool.manage": "Gestionează instrumente", + "workspace.member.manage": "Gestionează membrii", + "workspace.role.manage": "Gestionează permisiunile rolurilor și regulile de acces la resurse" +} diff --git a/web/i18n/ro-RO/permission.json b/web/i18n/ro-RO/permission.json new file mode 100644 index 00000000000..367f8a2069c --- /dev/null +++ b/web/i18n/ro-RO/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Acțiuni", + "accessRule.addMemberAria": "Adaugă {{name}}", + "accessRule.addMembersTitle": "Adaugă membri", + "accessRule.allPermittedMembers": "Toți membrii cu permisiuni de rol", + "accessRule.allPermittedMembersDescription": "Membrii cu permisiuni de rol corespunzătoare pot accesa această resursă.", + "accessRule.appDescription": "Controlează cui îi este deschisă această aplicație. Membrii au în continuare nevoie de permisiuni de rol pentru a o vizualiza sau opera.", + "accessRule.appTitle": "Reguli de acces ale aplicației", + "accessRule.changeOpenScopeDescription": "Modificarea domeniului de deschidere va reseta toate setările individuale de permisiuni pentru această resursă. Va trebui să adaugi din nou permisiunile specifice membrilor după comutare.", + "accessRule.changeOpenScopeTitle": "Schimbi domeniul de deschidere al resursei?", + "accessRule.collapseSection": "Restrânge {{title}}", + "accessRule.copied": "Regula de acces a fost copiată cu succes", + "accessRule.created": "Regula de acces a fost creată cu succes", + "accessRule.datasetDescription": "Controlează cui îi este deschisă această bază de cunoștințe. Membrii au în continuare nevoie de permisiuni de rol pentru a o vizualiza sau opera.", + "accessRule.datasetTitle": "Reguli de acces ale bazei de cunoștințe", + "accessRule.defaultPermission": "După permisiunile de rol", + "accessRule.deleteDescription": "Această regulă de acces va fi ștearsă definitiv și eliminată din lista de autorizare a resursei.", + "accessRule.deleteTitle": "Ștergi \"{{name}}\"?", + "accessRule.deleted": "Regula de acces a fost ștearsă cu succes", + "accessRule.exceptionPermissionFor": "Permisiune de excepție pentru {{name}}", + "accessRule.expandSection": "Extinde {{title}}", + "accessRule.individualPermissionSettings": "Setări individuale de permisiuni", + "accessRule.individualPermissionSettingsTip": "Setează excepții de permisiuni pentru colaboratori sau grupuri specifice. Aceste setări înlocuiesc nivelul de acces implicit.", + "accessRule.lockedSummary_one": "· {{count}} blocat", + "accessRule.lockedSummary_other": "· {{count}} blocate", + "accessRule.maintainer": "Întreținător", + "accessRule.member": "Membru", + "accessRule.newPermissionSet": "Set nou de permisiuni", + "accessRule.noAvailableMembers": "Niciun membru disponibil pentru adăugare", + "accessRule.noDescription": "Fără descriere", + "accessRule.noRoles": "Fără roluri", + "accessRule.noRules": "Fără reguli de acces", + "accessRule.noUserAccessSettings": "Fără setări individuale de permisiuni", + "accessRule.permission": "Permisiune", + "accessRule.resourceOpenScope": "Domeniul de deschidere al resursei", + "accessRule.resourceOpenScopeDescription": "Alege cui îi este deschisă această resursă. Permisiunile de rol decid în continuare ce poate face fiecare membru.", + "accessRule.specificMembersOnly": "Doar membri specifici", + "accessRule.specificMembersOnlyDescription": "Doar membrii selectați pot accesa această resursă.", + "accessRule.summary_one": "{{count}} set de permisiuni", + "accessRule.summary_other": "{{count}} seturi de permisiuni", + "accessRule.updated": "Regula de acces a fost actualizată cu succes", + "common.duplicateAction": "Duplică", + "group.app": "Aplicații", + "group.app_acl": "Permisiuni de acces ale aplicației", + "group.billing": "Facturare", + "group.credential": "Acreditive", + "group.dataset": "Baze de cunoștințe", + "group.dataset_acl": "Permisiuni de acces ale bazei de cunoștințe", + "group.integration": "Integrări", + "group.plugin": "Plugin-uri", + "group.tool_mcp": "Instrumente și MCP", + "group.workspace": "Spațiu de lucru", + "permissionList.clearAll": "Șterge tot", + "permissionList.collapseGroup": "Restrânge grupul", + "permissionList.expandGroup": "Extinde grupul", + "permissionList.noPermissionsFound": "Nu au fost găsite permisiuni", + "permissionList.selectAll": "Selectează tot", + "permissionSet.descriptionLabel": "Descriere", + "permissionSet.descriptionPlaceholder": "Descrie ce acordă acest set de permisiuni", + "permissionSet.learnMore": "Află mai multe despre permisiuni", + "permissionSet.modal.create.app.description": "Creează un set de permisiuni pentru aplicație care poate fi referențiat în regulile de acces pentru autorizare rapidă.", + "permissionSet.modal.create.app.title": "Creează set de permisiuni pentru aplicație", + "permissionSet.modal.create.dataset.description": "Creează un set de permisiuni pentru baza de cunoștințe care poate fi referențiat în regulile de acces pentru autorizare rapidă.", + "permissionSet.modal.create.dataset.title": "Creează set de permisiuni pentru baza de cunoștințe", + "permissionSet.modal.edit.app.description": "Modifică numele, descrierea și permisiunile acordate pentru acest set de permisiuni.", + "permissionSet.modal.edit.app.title": "Editează set de permisiuni pentru aplicație", + "permissionSet.modal.edit.dataset.description": "Modifică numele, descrierea și permisiunile acordate pentru acest set de permisiuni.", + "permissionSet.modal.edit.dataset.title": "Editează set de permisiuni pentru baza de cunoștințe", + "permissionSet.modal.view.app.description": "Vizualizează numele, descrierea și permisiunile acordate pentru acest set de permisiuni.", + "permissionSet.modal.view.app.title": "Vizualizează set de permisiuni pentru aplicație", + "permissionSet.modal.view.dataset.description": "Vizualizează numele, descrierea și permisiunile acordate pentru acest set de permisiuni.", + "permissionSet.modal.view.dataset.title": "Vizualizează set de permisiuni pentru baza de cunoștințe", + "permissionSet.nameLabel": "Numele setului de permisiuni", + "permissionSet.namePlaceholder": "de ex. Poate exporta DSL", + "permissionSet.permissions": "Permisiuni", + "role.addRole": "Creează roluri", + "role.copyMembersDescription_one": "\"{{name}}\" este atribuit unui {{count}} membru. Dorești ca noua copie a rolului să includă același membru?", + "role.copyMembersDescription_other": "\"{{name}}\" este atribuit la {{count}} membri. Dorești ca noua copie a rolului să includă aceiași membri?", + "role.copyMembersLoading": "Se încarcă atribuirile de membri...", + "role.copyMembersTitle": "Copiezi atribuirile de membri?", + "role.created": "Rolul a fost creat cu succes", + "role.deleteDescription": "Acest rol va fi șters definitiv și eliminat de la orice membri sau reguli de acces care îl utilizează.", + "role.deleteTitle": "Ștergi \"{{name}}\"?", + "role.deleted": "Rolul a fost șters cu succes", + "role.duplicated": "Rolul a fost duplicat cu succes", + "role.groups.builtin": "Roluri de sistem", + "role.groups.custom": "Roluri personalizate", + "role.loading": "Se încarcă rolurile...", + "role.modal.create.description": "Creează un rol și atribuie permisiuni", + "role.modal.create.title": "Creează rol", + "role.modal.descriptionLabel": "Descriere", + "role.modal.descriptionPlaceholder": "Descrie de ce este responsabil acest rol", + "role.modal.edit.description": "Editează detaliile și permisiunile rolului", + "role.modal.edit.title": "Editează rol", + "role.modal.nameLabel": "Numele rolului", + "role.modal.namePlaceholder": "de ex. Lider de marketing", + "role.modal.view.description": "Vizualizează detaliile și permisiunile rolului", + "role.modal.view.title": "Vizualizează rol", + "role.noDescription": "Fără descriere", + "role.noMatchingRoles": "Niciun rol corespunzător", + "role.searchPlaceholder": "Caută roluri...", + "role.updated": "Rolul a fost actualizat cu succes", + "role.workspaceRoles.description": "Creează roluri și definește ce poate face fiecare rol în acest spațiu de lucru.", + "role.workspaceRoles.title": "Roluri ale spațiului de lucru" +} diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json new file mode 100644 index 00000000000..f49dff7216c --- /dev/null +++ b/web/i18n/ru-RU/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Управление конфигурацией API-расширений", + "app.access_config": "Настройка прав доступа к приложению", + "app.acl.access_config": "Настройка прав доступа к приложению", + "app.acl.delete": "Удаление приложения", + "app.acl.edit": "Редактирование и оркестрация приложения", + "app.acl.import_export_dsl": "Импорт / экспорт DSL", + "app.acl.monitor": "Мониторинг и эксплуатация", + "app.acl.preview": "Предпросмотр приложения", + "app.acl.release_and_version": "Публикация приложения и управление версиями", + "app.acl.test_and_run": "Тестирование и использование приложения", + "app.acl.view_layout": "Страница оркестрации только для чтения", + "app.create_and_management": "Создание приложений и управление созданными вами приложениями", + "app.tag.manage": "Управление тегами приложений", + "app_library.access": "Доступ к библиотеке приложений", + "billing.manage": "Изменение тарифных планов", + "billing.subscription.manage": "Управление оплатой и подписками в биллинг-портале", + "billing.view": "Доступ к настройкам биллинга", + "credential.create": "Добавление учетных данных", + "credential.manage": "Редактирование и удаление учетных данных", + "credential.use": "Просмотр и использование учетных данных", + "customization.manage": "Управление кастомизацией", + "data_source.manage": "Управление конфигурацией источников данных", + "dataset.access_config": "Настройка прав доступа к базе знаний", + "dataset.acl.access_config": "Настройка прав доступа к базе знаний", + "dataset.acl.delete": "Удаление базы знаний", + "dataset.acl.delete_file": "Удаление файлов базы знаний", + "dataset.acl.document_download": "Скачивание документов", + "dataset.acl.edit": "Редактирование базы знаний", + "dataset.acl.import_export_dsl": "Импорт / экспорт DSL конвейера знаний", + "dataset.acl.pipeline_release": "Публикация конвейера знаний и управление версиями", + "dataset.acl.pipeline_test": "Тестирование конвейера", + "dataset.acl.preview": "Предпросмотр базы знаний", + "dataset.acl.readonly": "База знаний только для чтения", + "dataset.acl.retrieval_recall": "Извлечение из базы знаний", + "dataset.acl.use": "Добавление документов в базу знаний", + "dataset.api_key.manage": "Управление API-ключами базы знаний", + "dataset.create_and_management": "Создание баз знаний и управление созданными вами базами знаний", + "dataset.external.connect": "Подключение внешних баз знаний", + "dataset.tag.manage": "Управление тегами баз знаний", + "mcp.manage": "Управление MCP", + "plugin.debug": "Отладка плагинов", + "plugin.install": "Установка и обновление плагинов", + "plugin.manage": "Управление плагинами", + "plugin.plugin_preferences": "Управление настройками плагинов", + "snippets.create_and_modify": "Создание и изменение сниппетов", + "snippets.management": "Управление сниппетами", + "tool.manage": "Управление инструментами", + "workspace.member.manage": "Управление участниками", + "workspace.role.manage": "Управление правами ролей и правилами доступа к ресурсам" +} diff --git a/web/i18n/ru-RU/permission.json b/web/i18n/ru-RU/permission.json new file mode 100644 index 00000000000..f5a50c880dc --- /dev/null +++ b/web/i18n/ru-RU/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Действия", + "accessRule.addMemberAria": "Добавить {{name}}", + "accessRule.addMembersTitle": "Добавить участников", + "accessRule.allPermittedMembers": "Все участники с правами роли", + "accessRule.allPermittedMembersDescription": "Участники с соответствующими правами роли могут получить доступ к этому ресурсу.", + "accessRule.appDescription": "Управляйте тем, кому открыто это приложение. Участникам по-прежнему нужны права роли для просмотра или работы с ним.", + "accessRule.appTitle": "Правила доступа к приложению", + "accessRule.changeOpenScopeDescription": "Изменение области открытости сбросит все индивидуальные настройки прав для этого ресурса. После переключения вам потребуется снова добавить права для отдельных участников.", + "accessRule.changeOpenScopeTitle": "Изменить область открытости ресурса?", + "accessRule.collapseSection": "Свернуть {{title}}", + "accessRule.copied": "Правило доступа успешно скопировано", + "accessRule.created": "Правило доступа успешно создано", + "accessRule.datasetDescription": "Управляйте тем, кому открыта эта база знаний. Участникам по-прежнему нужны права роли для просмотра или работы с ней.", + "accessRule.datasetTitle": "Правила доступа к базе знаний", + "accessRule.defaultPermission": "По правам роли", + "accessRule.deleteDescription": "Это правило доступа будет безвозвратно удалено и исключено из списка авторизации ресурса.", + "accessRule.deleteTitle": "Удалить \"{{name}}\"?", + "accessRule.deleted": "Правило доступа успешно удалено", + "accessRule.exceptionPermissionFor": "Исключение из прав для {{name}}", + "accessRule.expandSection": "Развернуть {{title}}", + "accessRule.individualPermissionSettings": "Индивидуальные настройки прав", + "accessRule.individualPermissionSettingsTip": "Задайте исключения из прав для определенных участников или групп. Эти настройки переопределяют уровень доступа по умолчанию.", + "accessRule.lockedSummary_one": "· {{count}} заблокирован", + "accessRule.lockedSummary_other": "· {{count}} заблокировано", + "accessRule.maintainer": "Сопровождающий", + "accessRule.member": "Участник", + "accessRule.newPermissionSet": "Новый набор прав", + "accessRule.noAvailableMembers": "Нет участников для добавления", + "accessRule.noDescription": "Без описания", + "accessRule.noRoles": "Нет ролей", + "accessRule.noRules": "Нет правил доступа", + "accessRule.noUserAccessSettings": "Нет индивидуальных настроек прав", + "accessRule.permission": "Право", + "accessRule.resourceOpenScope": "Область открытости ресурса", + "accessRule.resourceOpenScopeDescription": "Выберите, кому открыт этот ресурс. Права роли по-прежнему определяют, что может делать каждый участник.", + "accessRule.specificMembersOnly": "Только определенные участники", + "accessRule.specificMembersOnlyDescription": "Доступ к этому ресурсу могут получить только выбранные участники.", + "accessRule.summary_one": "{{count}} набор прав", + "accessRule.summary_other": "{{count}} наборов прав", + "accessRule.updated": "Правило доступа успешно обновлено", + "common.duplicateAction": "Дублировать", + "group.app": "Приложения", + "group.app_acl": "Права доступа к приложению", + "group.billing": "Биллинг", + "group.credential": "Учетные данные", + "group.dataset": "Базы знаний", + "group.dataset_acl": "Права доступа к базе знаний", + "group.integration": "Интеграции", + "group.plugin": "Плагины", + "group.tool_mcp": "Инструменты и MCP", + "group.workspace": "Рабочее пространство", + "permissionList.clearAll": "Очистить все", + "permissionList.collapseGroup": "Свернуть группу", + "permissionList.expandGroup": "Развернуть группу", + "permissionList.noPermissionsFound": "Права не найдены", + "permissionList.selectAll": "Выбрать все", + "permissionSet.descriptionLabel": "Описание", + "permissionSet.descriptionPlaceholder": "Опишите, что предоставляет этот набор прав", + "permissionSet.learnMore": "Подробнее о правах", + "permissionSet.modal.create.app.description": "Создайте набор прав приложения, на который можно ссылаться в правилах доступа для быстрой авторизации.", + "permissionSet.modal.create.app.title": "Создать набор прав приложения", + "permissionSet.modal.create.dataset.description": "Создайте набор прав базы знаний, на который можно ссылаться в правилах доступа для быстрой авторизации.", + "permissionSet.modal.create.dataset.title": "Создать набор прав базы знаний", + "permissionSet.modal.edit.app.description": "Измените имя, описание и предоставляемые права для этого набора прав.", + "permissionSet.modal.edit.app.title": "Редактировать набор прав приложения", + "permissionSet.modal.edit.dataset.description": "Измените имя, описание и предоставляемые права для этого набора прав.", + "permissionSet.modal.edit.dataset.title": "Редактировать набор прав базы знаний", + "permissionSet.modal.view.app.description": "Просмотрите имя, описание и предоставляемые права для этого набора прав.", + "permissionSet.modal.view.app.title": "Просмотр набора прав приложения", + "permissionSet.modal.view.dataset.description": "Просмотрите имя, описание и предоставляемые права для этого набора прав.", + "permissionSet.modal.view.dataset.title": "Просмотр набора прав базы знаний", + "permissionSet.nameLabel": "Имя набора прав", + "permissionSet.namePlaceholder": "напр. Может экспортировать DSL", + "permissionSet.permissions": "Права", + "role.addRole": "Создать роли", + "role.copyMembersDescription_one": "\"{{name}}\" назначена {{count}} участнику. Хотите, чтобы копия новой роли включала того же участника?", + "role.copyMembersDescription_other": "\"{{name}}\" назначена {{count}} участникам. Хотите, чтобы копия новой роли включала тех же участников?", + "role.copyMembersLoading": "Загрузка назначений участников...", + "role.copyMembersTitle": "Скопировать назначения участников?", + "role.created": "Роль успешно создана", + "role.deleteDescription": "Эта роль будет безвозвратно удалена и исключена из всех участников и правил доступа, которые ее используют.", + "role.deleteTitle": "Удалить \"{{name}}\"?", + "role.deleted": "Роль успешно удалена", + "role.duplicated": "Роль успешно продублирована", + "role.groups.builtin": "Системные роли", + "role.groups.custom": "Пользовательские роли", + "role.loading": "Загрузка ролей...", + "role.modal.create.description": "Создайте роль и назначьте права", + "role.modal.create.title": "Создать роль", + "role.modal.descriptionLabel": "Описание", + "role.modal.descriptionPlaceholder": "Опишите, за что отвечает эта роль", + "role.modal.edit.description": "Редактировать данные роли и права", + "role.modal.edit.title": "Редактировать роль", + "role.modal.nameLabel": "Имя роли", + "role.modal.namePlaceholder": "напр. Руководитель маркетинга", + "role.modal.view.description": "Просмотр данных роли и прав", + "role.modal.view.title": "Просмотр роли", + "role.noDescription": "Без описания", + "role.noMatchingRoles": "Нет подходящих ролей", + "role.searchPlaceholder": "Поиск ролей...", + "role.updated": "Роль успешно обновлена", + "role.workspaceRoles.description": "Создавайте роли и определяйте, что каждая роль может делать в этом рабочем пространстве.", + "role.workspaceRoles.title": "Роли рабочего пространства" +} diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json new file mode 100644 index 00000000000..74ec9370429 --- /dev/null +++ b/web/i18n/sl-SI/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Upravljanje konfiguracije razširitve API", + "app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", + "app.acl.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", + "app.acl.delete": "Izbriši aplikacijo", + "app.acl.edit": "Uredi in orkestriraj aplikacijo", + "app.acl.import_export_dsl": "Uvozi / izvozi DSL", + "app.acl.monitor": "Spremljanje in operacije", + "app.acl.preview": "Predogled aplikacije", + "app.acl.release_and_version": "Objavljanje aplikacije in upravljanje različic", + "app.acl.test_and_run": "Preizkusi in uporabi aplikacijo", + "app.acl.view_layout": "Stran za orkestracijo samo za branje", + "app.create_and_management": "Ustvarjanje aplikacij in upravljanje aplikacij, ki ste jih ustvarili", + "app.tag.manage": "Upravljanje oznak aplikacij", + "app_library.access": "Dostop do knjižnice aplikacij", + "billing.manage": "Spremeni naročniške načrte", + "billing.subscription.manage": "Upravljanje obračunavanja in naročnin v portalu za obračunavanje", + "billing.view": "Dostop do nastavitev obračunavanja", + "credential.create": "Dodaj poverilnice", + "credential.manage": "Uredi in izbriši poverilnice", + "credential.use": "Ogled in uporaba poverilnic", + "customization.manage": "Upravljanje prilagoditev", + "data_source.manage": "Upravljanje konfiguracije vira podatkov", + "dataset.access_config": "Konfiguracija dovoljenj za dostop do baze znanja", + "dataset.acl.access_config": "Konfiguracija dovoljenj za dostop do baze znanja", + "dataset.acl.delete": "Izbriši bazo znanja", + "dataset.acl.delete_file": "Izbriši datoteke baze znanja", + "dataset.acl.document_download": "Prenesi dokumente", + "dataset.acl.edit": "Uredi bazo znanja", + "dataset.acl.import_export_dsl": "Uvozi / izvozi DSL cevovoda znanja", + "dataset.acl.pipeline_release": "Objavljanje cevovoda znanja in upravljanje različic", + "dataset.acl.pipeline_test": "Preizkušanje cevovoda", + "dataset.acl.preview": "Predogled baze znanja", + "dataset.acl.readonly": "Baza znanja samo za branje", + "dataset.acl.retrieval_recall": "Pridobivanje iz baze znanja", + "dataset.acl.use": "Dodaj dokumente v bazo znanja", + "dataset.api_key.manage": "Upravljanje API ključev baze znanja", + "dataset.create_and_management": "Ustvarjanje baz znanja in upravljanje baz znanja, ki ste jih ustvarili", + "dataset.external.connect": "Poveži zunanje baze znanja", + "dataset.tag.manage": "Upravljanje oznak baze znanja", + "mcp.manage": "Upravljanje MCP", + "plugin.debug": "Razhroščevanje vtičnikov", + "plugin.install": "Namesti in posodobi vtičnike", + "plugin.manage": "Upravljanje vtičnikov", + "plugin.plugin_preferences": "Upravljanje nastavitev vtičnikov", + "snippets.create_and_modify": "Ustvarjanje in spreminjanje izsekov", + "snippets.management": "Upravljanje izsekov", + "tool.manage": "Upravljanje orodij", + "workspace.member.manage": "Upravljanje članov", + "workspace.role.manage": "Upravljanje dovoljenj vlog in pravil za dostop do virov" +} diff --git a/web/i18n/sl-SI/permission.json b/web/i18n/sl-SI/permission.json new file mode 100644 index 00000000000..627641c04d7 --- /dev/null +++ b/web/i18n/sl-SI/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Dejanja", + "accessRule.addMemberAria": "Dodaj {{name}}", + "accessRule.addMembersTitle": "Dodaj člane", + "accessRule.allPermittedMembers": "Vsi člani z dovoljenji vlog", + "accessRule.allPermittedMembersDescription": "Člani z ustreznimi dovoljenji vlog lahko dostopajo do tega vira.", + "accessRule.appDescription": "Nadzorujte, komu je ta aplikacija na voljo. Člani še vedno potrebujejo dovoljenja vlog za ogled ali upravljanje.", + "accessRule.appTitle": "Pravila za dostop do aplikacije", + "accessRule.changeOpenScopeDescription": "Sprememba obsega odprtosti bo ponastavila vse individualne nastavitve dovoljenj za ta vir. Po preklopu boste morali znova dodati dovoljenja, specifična za člane.", + "accessRule.changeOpenScopeTitle": "Spremeni obseg odprtosti vira?", + "accessRule.collapseSection": "Strni {{title}}", + "accessRule.copied": "Pravilo za dostop uspešno kopirano", + "accessRule.created": "Pravilo za dostop uspešno ustvarjeno", + "accessRule.datasetDescription": "Nadzorujte, komu je ta baza znanja na voljo. Člani še vedno potrebujejo dovoljenja vlog za ogled ali upravljanje.", + "accessRule.datasetTitle": "Pravila za dostop do baze znanja", + "accessRule.defaultPermission": "Po dovoljenjih vlog", + "accessRule.deleteDescription": "To pravilo za dostop bo trajno izbrisano in odstranjeno s seznama avtorizacij vira.", + "accessRule.deleteTitle": "Izbrisati \"{{name}}\"?", + "accessRule.deleted": "Pravilo za dostop uspešno izbrisano", + "accessRule.exceptionPermissionFor": "Izjemno dovoljenje za {{name}}", + "accessRule.expandSection": "Razširi {{title}}", + "accessRule.individualPermissionSettings": "Individualne nastavitve dovoljenj", + "accessRule.individualPermissionSettingsTip": "Nastavite izjeme dovoljenj za določene sodelavce ali skupine. Te nastavitve preglasijo privzeto raven dostopa.", + "accessRule.lockedSummary_one": "· {{count}} zaklenjen", + "accessRule.lockedSummary_other": "· {{count}} zaklenjenih", + "accessRule.maintainer": "Vzdrževalec", + "accessRule.member": "Član", + "accessRule.newPermissionSet": "Nov nabor dovoljenj", + "accessRule.noAvailableMembers": "Ni članov, ki bi jih bilo mogoče dodati", + "accessRule.noDescription": "Brez opisa", + "accessRule.noRoles": "Brez vlog", + "accessRule.noRules": "Brez pravil za dostop", + "accessRule.noUserAccessSettings": "Brez individualnih nastavitev dovoljenj", + "accessRule.permission": "Dovoljenje", + "accessRule.resourceOpenScope": "Obseg odprtosti vira", + "accessRule.resourceOpenScopeDescription": "Izberite, komu je ta vir na voljo. Dovoljenja vlog še vedno določajo, kaj lahko vsak član počne.", + "accessRule.specificMembersOnly": "Samo določeni člani", + "accessRule.specificMembersOnlyDescription": "Do tega vira lahko dostopajo samo izbrani člani.", + "accessRule.summary_one": "{{count}} nabor dovoljenj", + "accessRule.summary_other": "{{count}} naborov dovoljenj", + "accessRule.updated": "Pravilo za dostop uspešno posodobljeno", + "common.duplicateAction": "Podvoji", + "group.app": "Aplikacije", + "group.app_acl": "Dovoljenja za dostop do aplikacije", + "group.billing": "Obračunavanje", + "group.credential": "Poverilnice", + "group.dataset": "Baze znanja", + "group.dataset_acl": "Dovoljenja za dostop do baze znanja", + "group.integration": "Integracije", + "group.plugin": "Vtičniki", + "group.tool_mcp": "Orodja in MCP", + "group.workspace": "Delovni prostor", + "permissionList.clearAll": "Počisti vse", + "permissionList.collapseGroup": "Strni skupino", + "permissionList.expandGroup": "Razširi skupino", + "permissionList.noPermissionsFound": "Ni najdenih dovoljenj", + "permissionList.selectAll": "Izberi vse", + "permissionSet.descriptionLabel": "Opis", + "permissionSet.descriptionPlaceholder": "Opišite, kaj ta nabor dovoljenj podeljuje", + "permissionSet.learnMore": "Več o dovoljenjih", + "permissionSet.modal.create.app.description": "Ustvarite nabor dovoljenj za aplikacijo, na katerega se je mogoče sklicevati v pravilih za dostop za hitro avtorizacijo.", + "permissionSet.modal.create.app.title": "Ustvari nabor dovoljenj za aplikacijo", + "permissionSet.modal.create.dataset.description": "Ustvarite nabor dovoljenj za bazo znanja, na katerega se je mogoče sklicevati v pravilih za dostop za hitro avtorizacijo.", + "permissionSet.modal.create.dataset.title": "Ustvari nabor dovoljenj za bazo znanja", + "permissionSet.modal.edit.app.description": "Spremenite ime, opis in dovoljenja, podeljena za ta nabor dovoljenj.", + "permissionSet.modal.edit.app.title": "Uredi nabor dovoljenj za aplikacijo", + "permissionSet.modal.edit.dataset.description": "Spremenite ime, opis in dovoljenja, podeljena za ta nabor dovoljenj.", + "permissionSet.modal.edit.dataset.title": "Uredi nabor dovoljenj za bazo znanja", + "permissionSet.modal.view.app.description": "Oglejte si ime, opis in dovoljenja, podeljena za ta nabor dovoljenj.", + "permissionSet.modal.view.app.title": "Ogled nabora dovoljenj za aplikacijo", + "permissionSet.modal.view.dataset.description": "Oglejte si ime, opis in dovoljenja, podeljena za ta nabor dovoljenj.", + "permissionSet.modal.view.dataset.title": "Ogled nabora dovoljenj za bazo znanja", + "permissionSet.nameLabel": "Ime nabora dovoljenj", + "permissionSet.namePlaceholder": "npr. Lahko izvozi DSL", + "permissionSet.permissions": "Dovoljenja", + "role.addRole": "Ustvari vloge", + "role.copyMembersDescription_one": "\"{{name}}\" je dodeljen {{count}} članu. Ali želite, da nova kopija vloge vključuje istega člana?", + "role.copyMembersDescription_other": "\"{{name}}\" je dodeljen {{count}} članom. Ali želite, da nova kopija vloge vključuje iste člane?", + "role.copyMembersLoading": "Nalaganje dodelitev članov...", + "role.copyMembersTitle": "Kopirati dodelitve članov?", + "role.created": "Vloga uspešno ustvarjena", + "role.deleteDescription": "Ta vloga bo trajno izbrisana in odstranjena iz vseh članov ali pravil za dostop, ki jo uporabljajo.", + "role.deleteTitle": "Izbrisati \"{{name}}\"?", + "role.deleted": "Vloga uspešno izbrisana", + "role.duplicated": "Vloga uspešno podvojena", + "role.groups.builtin": "Sistemske vloge", + "role.groups.custom": "Vloge po meri", + "role.loading": "Nalaganje vlog...", + "role.modal.create.description": "Ustvarite vlogo in dodelite dovoljenja", + "role.modal.create.title": "Ustvari vlogo", + "role.modal.descriptionLabel": "Opis", + "role.modal.descriptionPlaceholder": "Opišite, za kaj je ta vloga odgovorna", + "role.modal.edit.description": "Uredi podrobnosti in dovoljenja vloge", + "role.modal.edit.title": "Uredi vlogo", + "role.modal.nameLabel": "Ime vloge", + "role.modal.namePlaceholder": "npr. Vodja marketinga", + "role.modal.view.description": "Ogled podrobnosti in dovoljenj vloge", + "role.modal.view.title": "Ogled vloge", + "role.noDescription": "Brez opisa", + "role.noMatchingRoles": "Ni ujemajočih se vlog", + "role.searchPlaceholder": "Išči vloge...", + "role.updated": "Vloga uspešno posodobljena", + "role.workspaceRoles.description": "Ustvarite vloge in določite, kaj lahko vsaka vloga počne v tem delovnem prostoru.", + "role.workspaceRoles.title": "Vloge delovnega prostora" +} diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json new file mode 100644 index 00000000000..139287d1901 --- /dev/null +++ b/web/i18n/th-TH/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API", + "app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", + "app.acl.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", + "app.acl.delete": "ลบแอป", + "app.acl.edit": "แก้ไขและจัดวางแอป", + "app.acl.import_export_dsl": "นําเข้า / ส่งออก DSL", + "app.acl.monitor": "การตรวจสอบและการดําเนินการ", + "app.acl.preview": "ดูตัวอย่างแอป", + "app.acl.release_and_version": "การเผยแพร่แอปและการจัดการเวอร์ชัน", + "app.acl.test_and_run": "ทดสอบและใช้งานแอป", + "app.acl.view_layout": "หน้าจัดวางแบบอ่านอย่างเดียว", + "app.create_and_management": "สร้างแอปและจัดการแอปที่คุณสร้าง", + "app.tag.manage": "จัดการแท็กแอป", + "app_library.access": "เข้าถึงคลังแอป", + "billing.manage": "เปลี่ยนแผนการสมัครสมาชิก", + "billing.subscription.manage": "จัดการการเรียกเก็บเงินและการสมัครสมาชิกในพอร์ทัลการเรียกเก็บเงิน", + "billing.view": "เข้าถึงการตั้งค่าการเรียกเก็บเงิน", + "credential.create": "เพิ่มข้อมูลรับรอง", + "credential.manage": "แก้ไขและลบข้อมูลรับรอง", + "credential.use": "ดูและใช้ข้อมูลรับรอง", + "customization.manage": "จัดการการปรับแต่ง", + "data_source.manage": "จัดการการกําหนดค่าแหล่งข้อมูล", + "dataset.access_config": "กําหนดค่าสิทธิ์การเข้าถึงฐานความรู้", + "dataset.acl.access_config": "กําหนดค่าสิทธิ์การเข้าถึงฐานความรู้", + "dataset.acl.delete": "ลบฐานความรู้", + "dataset.acl.delete_file": "ลบไฟล์ฐานความรู้", + "dataset.acl.document_download": "ดาวน์โหลดเอกสาร", + "dataset.acl.edit": "แก้ไขฐานความรู้", + "dataset.acl.import_export_dsl": "นําเข้า / ส่งออก DSL ของไปป์ไลน์ความรู้", + "dataset.acl.pipeline_release": "การเผยแพร่ไปป์ไลน์ความรู้และการจัดการเวอร์ชัน", + "dataset.acl.pipeline_test": "การทดสอบไปป์ไลน์", + "dataset.acl.preview": "ดูตัวอย่างฐานความรู้", + "dataset.acl.readonly": "ฐานความรู้แบบอ่านอย่างเดียว", + "dataset.acl.retrieval_recall": "การดึงข้อมูลจากฐานความรู้", + "dataset.acl.use": "เพิ่มเอกสารลงในฐานความรู้", + "dataset.api_key.manage": "จัดการคีย์ API ของฐานความรู้", + "dataset.create_and_management": "สร้างฐานความรู้และจัดการฐานความรู้ที่คุณสร้าง", + "dataset.external.connect": "เชื่อมต่อฐานความรู้ภายนอก", + "dataset.tag.manage": "จัดการแท็กฐานความรู้", + "mcp.manage": "จัดการ MCP", + "plugin.debug": "ดีบักปลั๊กอิน", + "plugin.install": "ติดตั้งและอัปเดตปลั๊กอิน", + "plugin.manage": "จัดการปลั๊กอิน", + "plugin.plugin_preferences": "จัดการการตั้งค่าปลั๊กอิน", + "snippets.create_and_modify": "สร้างและแก้ไขสนิปเปต", + "snippets.management": "จัดการสนิปเปต", + "tool.manage": "จัดการเครื่องมือ", + "workspace.member.manage": "จัดการสมาชิก", + "workspace.role.manage": "จัดการสิทธิ์บทบาทและกฎการเข้าถึงทรัพยากร" +} diff --git a/web/i18n/th-TH/permission.json b/web/i18n/th-TH/permission.json new file mode 100644 index 00000000000..e52406958c9 --- /dev/null +++ b/web/i18n/th-TH/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "การดําเนินการ", + "accessRule.addMemberAria": "เพิ่ม {{name}}", + "accessRule.addMembersTitle": "เพิ่มสมาชิก", + "accessRule.allPermittedMembers": "สมาชิกทั้งหมดที่มีสิทธิ์ตามบทบาท", + "accessRule.allPermittedMembersDescription": "สมาชิกที่มีสิทธิ์ตามบทบาทที่ตรงกันสามารถเข้าถึงทรัพยากรนี้ได้", + "accessRule.appDescription": "ควบคุมว่าแอปนี้เปิดให้ใครเข้าถึง สมาชิกยังคงต้องมีสิทธิ์ตามบทบาทเพื่อดูหรือใช้งาน", + "accessRule.appTitle": "กฎการเข้าถึงแอป", + "accessRule.changeOpenScopeDescription": "การเปลี่ยนขอบเขตการเปิดจะรีเซ็ตการตั้งค่าสิทธิ์เฉพาะบุคคลทั้งหมดสําหรับทรัพยากรนี้ คุณจะต้องเพิ่มสิทธิ์เฉพาะสมาชิกอีกครั้งหลังจากเปลี่ยน", + "accessRule.changeOpenScopeTitle": "เปลี่ยนขอบเขตการเปิดของทรัพยากรหรือไม่", + "accessRule.collapseSection": "ยุบ {{title}}", + "accessRule.copied": "คัดลอกกฎการเข้าถึงสําเร็จ", + "accessRule.created": "สร้างกฎการเข้าถึงสําเร็จ", + "accessRule.datasetDescription": "ควบคุมว่าฐานความรู้นี้เปิดให้ใครเข้าถึง สมาชิกยังคงต้องมีสิทธิ์ตามบทบาทเพื่อดูหรือใช้งาน", + "accessRule.datasetTitle": "กฎการเข้าถึงฐานความรู้", + "accessRule.defaultPermission": "ตามสิทธิ์บทบาท", + "accessRule.deleteDescription": "กฎการเข้าถึงนี้จะถูกลบอย่างถาวรและนําออกจากรายการการอนุญาตทรัพยากร", + "accessRule.deleteTitle": "ลบ \"{{name}}\" หรือไม่", + "accessRule.deleted": "ลบกฎการเข้าถึงสําเร็จ", + "accessRule.exceptionPermissionFor": "สิทธิ์ข้อยกเว้นสําหรับ {{name}}", + "accessRule.expandSection": "ขยาย {{title}}", + "accessRule.individualPermissionSettings": "การตั้งค่าสิทธิ์เฉพาะบุคคล", + "accessRule.individualPermissionSettingsTip": "ตั้งค่าข้อยกเว้นสิทธิ์สําหรับผู้ร่วมงานหรือกลุ่มที่เฉพาะเจาะจง การตั้งค่าเหล่านี้จะแทนที่ระดับการเข้าถึงเริ่มต้น", + "accessRule.lockedSummary_one": "· ล็อก {{count}} รายการ", + "accessRule.lockedSummary_other": "· ล็อก {{count}} รายการ", + "accessRule.maintainer": "ผู้ดูแล", + "accessRule.member": "สมาชิก", + "accessRule.newPermissionSet": "ชุดสิทธิ์ใหม่", + "accessRule.noAvailableMembers": "ไม่มีสมาชิกที่จะเพิ่มได้", + "accessRule.noDescription": "ไม่มีคําอธิบาย", + "accessRule.noRoles": "ไม่มีบทบาท", + "accessRule.noRules": "ไม่มีกฎการเข้าถึง", + "accessRule.noUserAccessSettings": "ไม่มีการตั้งค่าสิทธิ์เฉพาะบุคคล", + "accessRule.permission": "สิทธิ์", + "accessRule.resourceOpenScope": "ขอบเขตการเปิดของทรัพยากร", + "accessRule.resourceOpenScopeDescription": "เลือกว่าทรัพยากรนี้เปิดให้ใครเข้าถึง สิทธิ์ตามบทบาทยังคงเป็นตัวกําหนดว่าสมาชิกแต่ละคนทําอะไรได้บ้าง", + "accessRule.specificMembersOnly": "เฉพาะสมาชิกที่ระบุเท่านั้น", + "accessRule.specificMembersOnlyDescription": "เฉพาะสมาชิกที่เลือกเท่านั้นที่สามารถเข้าถึงทรัพยากรนี้ได้", + "accessRule.summary_one": "ชุดสิทธิ์ {{count}} ชุด", + "accessRule.summary_other": "ชุดสิทธิ์ {{count}} ชุด", + "accessRule.updated": "อัปเดตกฎการเข้าถึงสําเร็จ", + "common.duplicateAction": "ทําซ้ํา", + "group.app": "แอปพลิเคชัน", + "group.app_acl": "สิทธิ์การเข้าถึงแอป", + "group.billing": "การเรียกเก็บเงิน", + "group.credential": "ข้อมูลรับรอง", + "group.dataset": "ฐานความรู้", + "group.dataset_acl": "สิทธิ์การเข้าถึงฐานความรู้", + "group.integration": "การเชื่อมต่อ", + "group.plugin": "ปลั๊กอิน", + "group.tool_mcp": "เครื่องมือและ MCP", + "group.workspace": "พื้นที่ทํางาน", + "permissionList.clearAll": "ล้างทั้งหมด", + "permissionList.collapseGroup": "ยุบกลุ่ม", + "permissionList.expandGroup": "ขยายกลุ่ม", + "permissionList.noPermissionsFound": "ไม่พบสิทธิ์", + "permissionList.selectAll": "เลือกทั้งหมด", + "permissionSet.descriptionLabel": "คําอธิบาย", + "permissionSet.descriptionPlaceholder": "อธิบายว่าชุดสิทธิ์นี้ให้สิทธิ์อะไรบ้าง", + "permissionSet.learnMore": "เรียนรู้เพิ่มเติมเกี่ยวกับสิทธิ์", + "permissionSet.modal.create.app.description": "สร้างชุดสิทธิ์แอปที่สามารถอ้างอิงในกฎการเข้าถึงเพื่อการอนุญาตที่รวดเร็ว", + "permissionSet.modal.create.app.title": "สร้างชุดสิทธิ์แอป", + "permissionSet.modal.create.dataset.description": "สร้างชุดสิทธิ์ฐานความรู้ที่สามารถอ้างอิงในกฎการเข้าถึงเพื่อการอนุญาตที่รวดเร็ว", + "permissionSet.modal.create.dataset.title": "สร้างชุดสิทธิ์ฐานความรู้", + "permissionSet.modal.edit.app.description": "แก้ไขชื่อ คําอธิบาย และสิทธิ์ที่ให้สําหรับชุดสิทธิ์นี้", + "permissionSet.modal.edit.app.title": "แก้ไขชุดสิทธิ์แอป", + "permissionSet.modal.edit.dataset.description": "แก้ไขชื่อ คําอธิบาย และสิทธิ์ที่ให้สําหรับชุดสิทธิ์นี้", + "permissionSet.modal.edit.dataset.title": "แก้ไขชุดสิทธิ์ฐานความรู้", + "permissionSet.modal.view.app.description": "ดูชื่อ คําอธิบาย และสิทธิ์ที่ให้สําหรับชุดสิทธิ์นี้", + "permissionSet.modal.view.app.title": "ดูชุดสิทธิ์แอป", + "permissionSet.modal.view.dataset.description": "ดูชื่อ คําอธิบาย และสิทธิ์ที่ให้สําหรับชุดสิทธิ์นี้", + "permissionSet.modal.view.dataset.title": "ดูชุดสิทธิ์ฐานความรู้", + "permissionSet.nameLabel": "ชื่อชุดสิทธิ์", + "permissionSet.namePlaceholder": "เช่น สามารถส่งออก DSL", + "permissionSet.permissions": "สิทธิ์", + "role.addRole": "สร้างบทบาท", + "role.copyMembersDescription_one": "\"{{name}}\" ถูกกําหนดให้กับสมาชิก {{count}} คน คุณต้องการให้สําเนาบทบาทใหม่รวมสมาชิกคนเดียวกันหรือไม่", + "role.copyMembersDescription_other": "\"{{name}}\" ถูกกําหนดให้กับสมาชิก {{count}} คน คุณต้องการให้สําเนาบทบาทใหม่รวมสมาชิกคนเดียวกันหรือไม่", + "role.copyMembersLoading": "กําลังโหลดการกําหนดสมาชิก...", + "role.copyMembersTitle": "คัดลอกการกําหนดสมาชิกหรือไม่", + "role.created": "สร้างบทบาทสําเร็จ", + "role.deleteDescription": "บทบาทนี้จะถูกลบอย่างถาวรและนําออกจากสมาชิกหรือกฎการเข้าถึงใด ๆ ที่ใช้บทบาทนี้", + "role.deleteTitle": "ลบ \"{{name}}\" หรือไม่", + "role.deleted": "ลบบทบาทสําเร็จ", + "role.duplicated": "ทําซ้ําบทบาทสําเร็จ", + "role.groups.builtin": "บทบาทระบบ", + "role.groups.custom": "บทบาทที่กําหนดเอง", + "role.loading": "กําลังโหลดบทบาท...", + "role.modal.create.description": "สร้างบทบาทและกําหนดสิทธิ์", + "role.modal.create.title": "สร้างบทบาท", + "role.modal.descriptionLabel": "คําอธิบาย", + "role.modal.descriptionPlaceholder": "อธิบายว่าบทบาทนี้รับผิดชอบอะไร", + "role.modal.edit.description": "แก้ไขรายละเอียดและสิทธิ์ของบทบาท", + "role.modal.edit.title": "แก้ไขบทบาท", + "role.modal.nameLabel": "ชื่อบทบาท", + "role.modal.namePlaceholder": "เช่น หัวหน้าฝ่ายการตลาด", + "role.modal.view.description": "ดูรายละเอียดและสิทธิ์ของบทบาท", + "role.modal.view.title": "ดูบทบาท", + "role.noDescription": "ไม่มีคําอธิบาย", + "role.noMatchingRoles": "ไม่มีบทบาทที่ตรงกัน", + "role.searchPlaceholder": "ค้นหาบทบาท...", + "role.updated": "อัปเดตบทบาทสําเร็จ", + "role.workspaceRoles.description": "สร้างบทบาทและกําหนดว่าแต่ละบทบาททําอะไรได้บ้างในพื้นที่ทํางานนี้", + "role.workspaceRoles.title": "บทบาทของพื้นที่ทํางาน" +} diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json new file mode 100644 index 00000000000..8e2009dea25 --- /dev/null +++ b/web/i18n/tr-TR/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "API uzantısı yapılandırmasını yönet", + "app.access_config": "Uygulama erişim izinlerini yapılandır", + "app.acl.access_config": "Uygulama erişim izinlerini yapılandır", + "app.acl.delete": "Uygulamayı sil", + "app.acl.edit": "Uygulamayı düzenle ve düzenle", + "app.acl.import_export_dsl": "DSL içe / dışa aktar", + "app.acl.monitor": "İzleme ve operasyonlar", + "app.acl.preview": "Uygulamayı önizle", + "app.acl.release_and_version": "Uygulama yayınlama ve sürüm yönetimi", + "app.acl.test_and_run": "Uygulamayı test et ve kullan", + "app.acl.view_layout": "Salt okunur düzenleme sayfası", + "app.create_and_management": "Uygulama oluştur ve oluşturduğun uygulamaları yönet", + "app.tag.manage": "Uygulama etiketlerini yönet", + "app_library.access": "Uygulama Kitaplığına eriş", + "billing.manage": "Abonelik planlarını değiştir", + "billing.subscription.manage": "Faturalandırma portalında faturalandırmayı ve abonelikleri yönet", + "billing.view": "Faturalandırma ayarlarına eriş", + "credential.create": "Kimlik bilgisi ekle", + "credential.manage": "Kimlik bilgilerini düzenle ve sil", + "credential.use": "Kimlik bilgilerini görüntüle ve kullan", + "customization.manage": "Özelleştirmeyi yönet", + "data_source.manage": "Veri kaynağı yapılandırmasını yönet", + "dataset.access_config": "Bilgi tabanı erişim izinlerini yapılandır", + "dataset.acl.access_config": "Bilgi tabanı erişim izinlerini yapılandır", + "dataset.acl.delete": "Bilgi tabanını sil", + "dataset.acl.delete_file": "Bilgi tabanı dosyalarını sil", + "dataset.acl.document_download": "Belgeleri indir", + "dataset.acl.edit": "Bilgi tabanını düzenle", + "dataset.acl.import_export_dsl": "Bilgi işlem hattı DSL'sini içe / dışa aktar", + "dataset.acl.pipeline_release": "Bilgi işlem hattı yayınlama ve sürüm yönetimi", + "dataset.acl.pipeline_test": "İşlem hattı testi", + "dataset.acl.preview": "Bilgi tabanını önizle", + "dataset.acl.readonly": "Salt okunur bilgi tabanı", + "dataset.acl.retrieval_recall": "Bilgi tabanı geri alımı", + "dataset.acl.use": "Bilgi tabanına belge ekle", + "dataset.api_key.manage": "Bilgi tabanı API anahtarlarını yönet", + "dataset.create_and_management": "Bilgi tabanı oluştur ve oluşturduğun bilgi tabanlarını yönet", + "dataset.external.connect": "Harici bilgi tabanlarına bağlan", + "dataset.tag.manage": "Bilgi tabanı etiketlerini yönet", + "mcp.manage": "MCP yönet", + "plugin.debug": "Eklentileri hata ayıkla", + "plugin.install": "Eklentileri yükle ve güncelle", + "plugin.manage": "Eklentileri yönet", + "plugin.plugin_preferences": "Eklenti tercihlerini yönet", + "snippets.create_and_modify": "Snippet oluştur ve değiştir", + "snippets.management": "Snippet'leri yönet", + "tool.manage": "Araçları yönet", + "workspace.member.manage": "Üyeleri yönet", + "workspace.role.manage": "Rol izinlerini ve kaynak erişim kurallarını yönet" +} diff --git a/web/i18n/tr-TR/permission.json b/web/i18n/tr-TR/permission.json new file mode 100644 index 00000000000..86818102126 --- /dev/null +++ b/web/i18n/tr-TR/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Eylemler", + "accessRule.addMemberAria": "{{name}} ekle", + "accessRule.addMembersTitle": "Üye ekle", + "accessRule.allPermittedMembers": "Rol izinlerine sahip tüm üyeler", + "accessRule.allPermittedMembersDescription": "Eşleşen rol izinlerine sahip üyeler bu kaynağa erişebilir.", + "accessRule.appDescription": "Bu uygulamanın kime açık olduğunu kontrol edin. Üyelerin yine de onu görüntülemek veya işletmek için rol izinlerine ihtiyacı vardır.", + "accessRule.appTitle": "Uygulama Erişim Kuralları", + "accessRule.changeOpenScopeDescription": "Açık kapsamı değiştirmek bu kaynak için tüm bireysel izin ayarlarını sıfırlar. Geçiş yaptıktan sonra üyeye özel izinleri yeniden eklemeniz gerekir.", + "accessRule.changeOpenScopeTitle": "Kaynak açık kapsamı değiştirilsin mi?", + "accessRule.collapseSection": "{{title}} daralt", + "accessRule.copied": "Erişim kuralı başarıyla kopyalandı", + "accessRule.created": "Erişim kuralı başarıyla oluşturuldu", + "accessRule.datasetDescription": "Bu bilgi tabanının kime açık olduğunu kontrol edin. Üyelerin yine de onu görüntülemek veya işletmek için rol izinlerine ihtiyacı vardır.", + "accessRule.datasetTitle": "Bilgi Tabanı Erişim Kuralları", + "accessRule.defaultPermission": "Rol izinlerine göre", + "accessRule.deleteDescription": "Bu erişim kuralı kalıcı olarak silinecek ve kaynak yetkilendirme listesinden kaldırılacaktır.", + "accessRule.deleteTitle": "\"{{name}}\" silinsin mi?", + "accessRule.deleted": "Erişim kuralı başarıyla silindi", + "accessRule.exceptionPermissionFor": "{{name}} için istisna izni", + "accessRule.expandSection": "{{title}} genişlet", + "accessRule.individualPermissionSettings": "Bireysel izin ayarları", + "accessRule.individualPermissionSettingsTip": "Belirli işbirlikçiler veya gruplar için izin istisnaları ayarlayın. Bu ayarlar varsayılan erişim düzeyini geçersiz kılar.", + "accessRule.lockedSummary_one": "· {{count}} kilitli", + "accessRule.lockedSummary_other": "· {{count}} kilitli", + "accessRule.maintainer": "Bakımcı", + "accessRule.member": "Üye", + "accessRule.newPermissionSet": "Yeni izin kümesi", + "accessRule.noAvailableMembers": "Eklenecek uygun üye yok", + "accessRule.noDescription": "Açıklama yok", + "accessRule.noRoles": "Rol yok", + "accessRule.noRules": "Erişim kuralı yok", + "accessRule.noUserAccessSettings": "Bireysel izin ayarı yok", + "accessRule.permission": "İzin", + "accessRule.resourceOpenScope": "Kaynak açık kapsamı", + "accessRule.resourceOpenScopeDescription": "Bu kaynağın kime açık olduğunu seçin. Her üyenin ne yapabileceğine yine de rol izinleri karar verir.", + "accessRule.specificMembersOnly": "Yalnızca belirli üyeler", + "accessRule.specificMembersOnlyDescription": "Bu kaynağa yalnızca seçilen üyeler erişebilir.", + "accessRule.summary_one": "{{count}} izin kümesi", + "accessRule.summary_other": "{{count}} izin kümesi", + "accessRule.updated": "Erişim kuralı başarıyla güncellendi", + "common.duplicateAction": "Çoğalt", + "group.app": "Uygulamalar", + "group.app_acl": "Uygulama erişim izinleri", + "group.billing": "Faturalandırma", + "group.credential": "Kimlik Bilgileri", + "group.dataset": "Bilgi tabanları", + "group.dataset_acl": "Bilgi tabanı erişim izinleri", + "group.integration": "Entegrasyonlar", + "group.plugin": "Eklentiler", + "group.tool_mcp": "Araçlar ve MCP", + "group.workspace": "Çalışma alanı", + "permissionList.clearAll": "Tümünü temizle", + "permissionList.collapseGroup": "Grubu daralt", + "permissionList.expandGroup": "Grubu genişlet", + "permissionList.noPermissionsFound": "İzin bulunamadı", + "permissionList.selectAll": "Tümünü seç", + "permissionSet.descriptionLabel": "Açıklama", + "permissionSet.descriptionPlaceholder": "Bu izin kümesinin ne sağladığını açıklayın", + "permissionSet.learnMore": "İzinler hakkında daha fazla bilgi edinin", + "permissionSet.modal.create.app.description": "Hızlı yetkilendirme için erişim kurallarında başvurulabilen bir uygulama izin kümesi oluşturun.", + "permissionSet.modal.create.app.title": "Uygulama izin kümesi oluştur", + "permissionSet.modal.create.dataset.description": "Hızlı yetkilendirme için erişim kurallarında başvurulabilen bir bilgi tabanı izin kümesi oluşturun.", + "permissionSet.modal.create.dataset.title": "Bilgi Tabanı izin kümesi oluştur", + "permissionSet.modal.edit.app.description": "Bu izin kümesi için verilen adı, açıklamayı ve izinleri değiştirin.", + "permissionSet.modal.edit.app.title": "Uygulama izin kümesini düzenle", + "permissionSet.modal.edit.dataset.description": "Bu izin kümesi için verilen adı, açıklamayı ve izinleri değiştirin.", + "permissionSet.modal.edit.dataset.title": "Bilgi Tabanı izin kümesini düzenle", + "permissionSet.modal.view.app.description": "Bu izin kümesi için verilen adı, açıklamayı ve izinleri görüntüleyin.", + "permissionSet.modal.view.app.title": "Uygulama izin kümesini görüntüle", + "permissionSet.modal.view.dataset.description": "Bu izin kümesi için verilen adı, açıklamayı ve izinleri görüntüleyin.", + "permissionSet.modal.view.dataset.title": "Bilgi Tabanı izin kümesini görüntüle", + "permissionSet.nameLabel": "İzin kümesi adı", + "permissionSet.namePlaceholder": "ör. DSL dışa aktarabilir", + "permissionSet.permissions": "İzinler", + "role.addRole": "Rol oluştur", + "role.copyMembersDescription_one": "\"{{name}}\" {{count}} üyeye atanmış. Yeni rol kopyasının aynı üyeyi içermesini istiyor musunuz?", + "role.copyMembersDescription_other": "\"{{name}}\" {{count}} üyeye atanmış. Yeni rol kopyasının aynı üyeleri içermesini istiyor musunuz?", + "role.copyMembersLoading": "Üye atamaları yükleniyor...", + "role.copyMembersTitle": "Üye atamaları kopyalansın mı?", + "role.created": "Rol başarıyla oluşturuldu", + "role.deleteDescription": "Bu rol kalıcı olarak silinecek ve onu kullanan tüm üyelerden veya erişim kurallarından kaldırılacaktır.", + "role.deleteTitle": "\"{{name}}\" silinsin mi?", + "role.deleted": "Rol başarıyla silindi", + "role.duplicated": "Rol başarıyla çoğaltıldı", + "role.groups.builtin": "Sistem Rolleri", + "role.groups.custom": "Özel Roller", + "role.loading": "Roller yükleniyor...", + "role.modal.create.description": "Bir rol oluşturun ve izinler atayın", + "role.modal.create.title": "Rol Oluştur", + "role.modal.descriptionLabel": "Açıklama", + "role.modal.descriptionPlaceholder": "Bu rolün neyden sorumlu olduğunu açıklayın", + "role.modal.edit.description": "Rol ayrıntılarını ve izinlerini düzenle", + "role.modal.edit.title": "Rolü Düzenle", + "role.modal.nameLabel": "Rol adı", + "role.modal.namePlaceholder": "ör. Pazarlama Lideri", + "role.modal.view.description": "Rol ayrıntılarını ve izinlerini görüntüle", + "role.modal.view.title": "Rolü Görüntüle", + "role.noDescription": "Açıklama yok", + "role.noMatchingRoles": "Eşleşen rol yok", + "role.searchPlaceholder": "Rolleri ara...", + "role.updated": "Rol başarıyla güncellendi", + "role.workspaceRoles.description": "Roller oluşturun ve bu çalışma alanında her rolün ne yapabileceğini tanımlayın.", + "role.workspaceRoles.title": "Çalışma Alanı Rolleri" +} diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json new file mode 100644 index 00000000000..1639eb65cba --- /dev/null +++ b/web/i18n/uk-UA/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Керування конфігурацією розширення API", + "app.access_config": "Налаштування дозволів доступу до застосунку", + "app.acl.access_config": "Налаштування дозволів доступу до застосунку", + "app.acl.delete": "Видалити застосунок", + "app.acl.edit": "Редагувати та оркеструвати застосунок", + "app.acl.import_export_dsl": "Імпорт / експорт DSL", + "app.acl.monitor": "Моніторинг та операції", + "app.acl.preview": "Попередній перегляд застосунку", + "app.acl.release_and_version": "Публікація застосунку та керування версіями", + "app.acl.test_and_run": "Тестувати та використовувати застосунок", + "app.acl.view_layout": "Сторінка оркестрації лише для читання", + "app.create_and_management": "Створювати застосунки та керувати створеними вами застосунками", + "app.tag.manage": "Керування тегами застосунків", + "app_library.access": "Доступ до бібліотеки застосунків", + "billing.manage": "Зміна планів підписки", + "billing.subscription.manage": "Керування рахунками та підписками в порталі оплати", + "billing.view": "Доступ до налаштувань оплати", + "credential.create": "Додати облікові дані", + "credential.manage": "Редагувати та видаляти облікові дані", + "credential.use": "Переглядати та використовувати облікові дані", + "customization.manage": "Керування налаштуванням вигляду", + "data_source.manage": "Керування конфігурацією джерела даних", + "dataset.access_config": "Налаштування дозволів доступу до бази знань", + "dataset.acl.access_config": "Налаштування дозволів доступу до бази знань", + "dataset.acl.delete": "Видалити базу знань", + "dataset.acl.delete_file": "Видалити файли бази знань", + "dataset.acl.document_download": "Завантажувати документи", + "dataset.acl.edit": "Редагувати базу знань", + "dataset.acl.import_export_dsl": "Імпорт / експорт DSL конвеєра знань", + "dataset.acl.pipeline_release": "Публікація конвеєра знань та керування версіями", + "dataset.acl.pipeline_test": "Тестування конвеєра", + "dataset.acl.preview": "Попередній перегляд бази знань", + "dataset.acl.readonly": "База знань лише для читання", + "dataset.acl.retrieval_recall": "Пошук у базі знань", + "dataset.acl.use": "Додавати документи до бази знань", + "dataset.api_key.manage": "Керування ключами API бази знань", + "dataset.create_and_management": "Створювати бази знань та керувати створеними вами базами знань", + "dataset.external.connect": "Підключати зовнішні бази знань", + "dataset.tag.manage": "Керування тегами бази знань", + "mcp.manage": "Керування MCP", + "plugin.debug": "Налагодження плагінів", + "plugin.install": "Встановлення та оновлення плагінів", + "plugin.manage": "Керування плагінами", + "plugin.plugin_preferences": "Керування налаштуваннями плагінів", + "snippets.create_and_modify": "Створювати та змінювати фрагменти", + "snippets.management": "Керування фрагментами", + "tool.manage": "Керування інструментами", + "workspace.member.manage": "Керування учасниками", + "workspace.role.manage": "Керування дозволами ролей та правилами доступу до ресурсів" +} diff --git a/web/i18n/uk-UA/permission.json b/web/i18n/uk-UA/permission.json new file mode 100644 index 00000000000..c035dbe4dfd --- /dev/null +++ b/web/i18n/uk-UA/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Дії", + "accessRule.addMemberAria": "Додати {{name}}", + "accessRule.addMembersTitle": "Додати учасників", + "accessRule.allPermittedMembers": "Усі учасники з дозволами ролей", + "accessRule.allPermittedMembersDescription": "Учасники з відповідними дозволами ролей можуть отримати доступ до цього ресурсу.", + "accessRule.appDescription": "Контролюйте, кому відкрито цей застосунок. Учасникам усе одно потрібні дозволи ролей, щоб переглядати його чи працювати з ним.", + "accessRule.appTitle": "Правила доступу до застосунку", + "accessRule.changeOpenScopeDescription": "Зміна сфери відкритості скине всі індивідуальні налаштування дозволів для цього ресурсу. Після перемикання вам потрібно буде знову додати дозволи для конкретних учасників.", + "accessRule.changeOpenScopeTitle": "Змінити сферу відкритості ресурсу?", + "accessRule.collapseSection": "Згорнути {{title}}", + "accessRule.copied": "Правило доступу успішно скопійовано", + "accessRule.created": "Правило доступу успішно створено", + "accessRule.datasetDescription": "Контролюйте, кому відкрито цю базу знань. Учасникам усе одно потрібні дозволи ролей, щоб переглядати її чи працювати з нею.", + "accessRule.datasetTitle": "Правила доступу до бази знань", + "accessRule.defaultPermission": "За дозволами ролей", + "accessRule.deleteDescription": "Це правило доступу буде остаточно видалено та вилучено зі списку авторизації ресурсу.", + "accessRule.deleteTitle": "Видалити \"{{name}}\"?", + "accessRule.deleted": "Правило доступу успішно видалено", + "accessRule.exceptionPermissionFor": "Винятковий дозвіл для {{name}}", + "accessRule.expandSection": "Розгорнути {{title}}", + "accessRule.individualPermissionSettings": "Індивідуальні налаштування дозволів", + "accessRule.individualPermissionSettingsTip": "Установіть винятки дозволів для конкретних співавторів або груп. Ці налаштування перевизначають типовий рівень доступу.", + "accessRule.lockedSummary_one": "· {{count}} заблоковано", + "accessRule.lockedSummary_other": "· {{count}} заблоковано", + "accessRule.maintainer": "Супроводжувач", + "accessRule.member": "Учасник", + "accessRule.newPermissionSet": "Новий набір дозволів", + "accessRule.noAvailableMembers": "Немає доступних учасників для додавання", + "accessRule.noDescription": "Немає опису", + "accessRule.noRoles": "Немає ролей", + "accessRule.noRules": "Немає правил доступу", + "accessRule.noUserAccessSettings": "Немає індивідуальних налаштувань дозволів", + "accessRule.permission": "Дозвіл", + "accessRule.resourceOpenScope": "Сфера відкритості ресурсу", + "accessRule.resourceOpenScopeDescription": "Виберіть, кому відкрито цей ресурс. Дозволи ролей усе одно визначають, що може робити кожен учасник.", + "accessRule.specificMembersOnly": "Лише певні учасники", + "accessRule.specificMembersOnlyDescription": "Доступ до цього ресурсу можуть отримати лише вибрані учасники.", + "accessRule.summary_one": "{{count}} набір дозволів", + "accessRule.summary_other": "{{count}} наборів дозволів", + "accessRule.updated": "Правило доступу успішно оновлено", + "common.duplicateAction": "Дублювати", + "group.app": "Застосунки", + "group.app_acl": "Дозволи доступу до застосунку", + "group.billing": "Оплата", + "group.credential": "Облікові дані", + "group.dataset": "Бази знань", + "group.dataset_acl": "Дозволи доступу до бази знань", + "group.integration": "Інтеграції", + "group.plugin": "Плагіни", + "group.tool_mcp": "Інструменти та MCP", + "group.workspace": "Робочий простір", + "permissionList.clearAll": "Очистити все", + "permissionList.collapseGroup": "Згорнути групу", + "permissionList.expandGroup": "Розгорнути групу", + "permissionList.noPermissionsFound": "Дозволів не знайдено", + "permissionList.selectAll": "Вибрати все", + "permissionSet.descriptionLabel": "Опис", + "permissionSet.descriptionPlaceholder": "Опишіть, що надає цей набір дозволів", + "permissionSet.learnMore": "Дізнатися більше про дозволи", + "permissionSet.modal.create.app.description": "Створіть набір дозволів застосунку, на який можна посилатися в правилах доступу для швидкої авторизації.", + "permissionSet.modal.create.app.title": "Створити набір дозволів застосунку", + "permissionSet.modal.create.dataset.description": "Створіть набір дозволів бази знань, на який можна посилатися в правилах доступу для швидкої авторизації.", + "permissionSet.modal.create.dataset.title": "Створити набір дозволів бази знань", + "permissionSet.modal.edit.app.description": "Змініть назву, опис та надані дозволи для цього набору дозволів.", + "permissionSet.modal.edit.app.title": "Редагувати набір дозволів застосунку", + "permissionSet.modal.edit.dataset.description": "Змініть назву, опис та надані дозволи для цього набору дозволів.", + "permissionSet.modal.edit.dataset.title": "Редагувати набір дозволів бази знань", + "permissionSet.modal.view.app.description": "Перегляньте назву, опис та надані дозволи для цього набору дозволів.", + "permissionSet.modal.view.app.title": "Переглянути набір дозволів застосунку", + "permissionSet.modal.view.dataset.description": "Перегляньте назву, опис та надані дозволи для цього набору дозволів.", + "permissionSet.modal.view.dataset.title": "Переглянути набір дозволів бази знань", + "permissionSet.nameLabel": "Назва набору дозволів", + "permissionSet.namePlaceholder": "напр. Може експортувати DSL", + "permissionSet.permissions": "Дозволи", + "role.addRole": "Створити ролі", + "role.copyMembersDescription_one": "\"{{name}}\" призначено {{count}} учаснику. Чи хочете ви, щоб нова копія ролі включала того самого учасника?", + "role.copyMembersDescription_other": "\"{{name}}\" призначено {{count}} учасникам. Чи хочете ви, щоб нова копія ролі включала тих самих учасників?", + "role.copyMembersLoading": "Завантаження призначень учасників...", + "role.copyMembersTitle": "Скопіювати призначення учасників?", + "role.created": "Роль успішно створено", + "role.deleteDescription": "Цю роль буде остаточно видалено та вилучено з усіх учасників або правил доступу, які її використовують.", + "role.deleteTitle": "Видалити \"{{name}}\"?", + "role.deleted": "Роль успішно видалено", + "role.duplicated": "Роль успішно продубльовано", + "role.groups.builtin": "Системні ролі", + "role.groups.custom": "Користувацькі ролі", + "role.loading": "Завантаження ролей...", + "role.modal.create.description": "Створіть роль та призначте дозволи", + "role.modal.create.title": "Створити роль", + "role.modal.descriptionLabel": "Опис", + "role.modal.descriptionPlaceholder": "Опишіть, за що відповідає ця роль", + "role.modal.edit.description": "Редагувати деталі ролі та дозволи", + "role.modal.edit.title": "Редагувати роль", + "role.modal.nameLabel": "Назва ролі", + "role.modal.namePlaceholder": "напр. Керівник маркетингу", + "role.modal.view.description": "Переглянути деталі ролі та дозволи", + "role.modal.view.title": "Переглянути роль", + "role.noDescription": "Немає опису", + "role.noMatchingRoles": "Немає відповідних ролей", + "role.searchPlaceholder": "Пошук ролей...", + "role.updated": "Роль успішно оновлено", + "role.workspaceRoles.description": "Створюйте ролі та визначайте, що може робити кожна роль у цьому робочому просторі.", + "role.workspaceRoles.title": "Ролі робочого простору" +} diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json new file mode 100644 index 00000000000..defaa08969f --- /dev/null +++ b/web/i18n/vi-VN/permission-keys.json @@ -0,0 +1,51 @@ +{ + "api_extension.manage": "Quản lý cấu hình phần mở rộng API", + "app.access_config": "Cấu hình quyền truy cập ứng dụng", + "app.acl.access_config": "Cấu hình quyền truy cập ứng dụng", + "app.acl.delete": "Xóa ứng dụng", + "app.acl.edit": "Chỉnh sửa và điều phối ứng dụng", + "app.acl.import_export_dsl": "Nhập / xuất DSL", + "app.acl.monitor": "Giám sát và vận hành", + "app.acl.preview": "Xem trước ứng dụng", + "app.acl.release_and_version": "Phát hành ứng dụng và quản lý phiên bản", + "app.acl.test_and_run": "Kiểm thử và sử dụng ứng dụng", + "app.acl.view_layout": "Trang điều phối chỉ đọc", + "app.create_and_management": "Tạo ứng dụng và quản lý các ứng dụng bạn đã tạo", + "app.tag.manage": "Quản lý thẻ ứng dụng", + "app_library.access": "Truy cập Thư viện ứng dụng", + "billing.manage": "Thay đổi gói đăng ký", + "billing.subscription.manage": "Quản lý thanh toán và đăng ký trong cổng thanh toán", + "billing.view": "Truy cập cài đặt Thanh toán", + "credential.create": "Thêm thông tin xác thực", + "credential.manage": "Chỉnh sửa và xóa thông tin xác thực", + "credential.use": "Xem và sử dụng thông tin xác thực", + "customization.manage": "Quản lý tùy chỉnh", + "data_source.manage": "Quản lý cấu hình nguồn dữ liệu", + "dataset.access_config": "Cấu hình quyền truy cập cơ sở tri thức", + "dataset.acl.access_config": "Cấu hình quyền truy cập cơ sở tri thức", + "dataset.acl.delete": "Xóa cơ sở tri thức", + "dataset.acl.delete_file": "Xóa tệp cơ sở tri thức", + "dataset.acl.document_download": "Tải xuống tài liệu", + "dataset.acl.edit": "Chỉnh sửa cơ sở tri thức", + "dataset.acl.import_export_dsl": "Nhập / xuất DSL quy trình tri thức", + "dataset.acl.pipeline_release": "Phát hành quy trình tri thức và quản lý phiên bản", + "dataset.acl.pipeline_test": "Kiểm thử quy trình", + "dataset.acl.preview": "Xem trước cơ sở tri thức", + "dataset.acl.readonly": "Cơ sở tri thức chỉ đọc", + "dataset.acl.retrieval_recall": "Truy xuất cơ sở tri thức", + "dataset.acl.use": "Thêm tài liệu vào cơ sở tri thức", + "dataset.api_key.manage": "Quản lý khóa API cơ sở tri thức", + "dataset.create_and_management": "Tạo cơ sở tri thức và quản lý các cơ sở tri thức bạn đã tạo", + "dataset.external.connect": "Kết nối cơ sở tri thức bên ngoài", + "dataset.tag.manage": "Quản lý thẻ cơ sở tri thức", + "mcp.manage": "Quản lý MCP", + "plugin.debug": "Gỡ lỗi plugin", + "plugin.install": "Cài đặt và cập nhật plugin", + "plugin.manage": "Quản lý plugin", + "plugin.plugin_preferences": "Quản lý tùy chọn plugin", + "snippets.create_and_modify": "Tạo và chỉnh sửa đoạn mã", + "snippets.management": "Quản lý đoạn mã", + "tool.manage": "Quản lý công cụ", + "workspace.member.manage": "Quản lý thành viên", + "workspace.role.manage": "Quản lý quyền vai trò và quy tắc truy cập tài nguyên" +} diff --git a/web/i18n/vi-VN/permission.json b/web/i18n/vi-VN/permission.json new file mode 100644 index 00000000000..5ebadd022a3 --- /dev/null +++ b/web/i18n/vi-VN/permission.json @@ -0,0 +1,105 @@ +{ + "accessRule.actions": "Hành động", + "accessRule.addMemberAria": "Thêm {{name}}", + "accessRule.addMembersTitle": "Thêm thành viên", + "accessRule.allPermittedMembers": "Tất cả thành viên có quyền vai trò", + "accessRule.allPermittedMembersDescription": "Các thành viên có quyền vai trò phù hợp có thể truy cập tài nguyên này.", + "accessRule.appDescription": "Kiểm soát ứng dụng này được mở cho ai. Các thành viên vẫn cần quyền vai trò để xem hoặc vận hành nó.", + "accessRule.appTitle": "Quy tắc truy cập ứng dụng", + "accessRule.changeOpenScopeDescription": "Việc thay đổi phạm vi mở sẽ đặt lại tất cả cài đặt quyền riêng lẻ cho tài nguyên này. Bạn sẽ cần thêm lại các quyền dành riêng cho thành viên sau khi chuyển đổi.", + "accessRule.changeOpenScopeTitle": "Thay đổi phạm vi mở của tài nguyên?", + "accessRule.collapseSection": "Thu gọn {{title}}", + "accessRule.copied": "Đã sao chép quy tắc truy cập thành công", + "accessRule.created": "Đã tạo quy tắc truy cập thành công", + "accessRule.datasetDescription": "Kiểm soát cơ sở tri thức này được mở cho ai. Các thành viên vẫn cần quyền vai trò để xem hoặc vận hành nó.", + "accessRule.datasetTitle": "Quy tắc truy cập cơ sở tri thức", + "accessRule.defaultPermission": "Theo quyền vai trò", + "accessRule.deleteDescription": "Quy tắc truy cập này sẽ bị xóa vĩnh viễn và bị loại bỏ khỏi danh sách ủy quyền tài nguyên.", + "accessRule.deleteTitle": "Xóa \"{{name}}\"?", + "accessRule.deleted": "Đã xóa quy tắc truy cập thành công", + "accessRule.exceptionPermissionFor": "Quyền ngoại lệ cho {{name}}", + "accessRule.expandSection": "Mở rộng {{title}}", + "accessRule.individualPermissionSettings": "Cài đặt quyền riêng lẻ", + "accessRule.individualPermissionSettingsTip": "Đặt các ngoại lệ về quyền cho các cộng tác viên hoặc nhóm cụ thể. Các cài đặt này sẽ ghi đè cấp độ truy cập mặc định.", + "accessRule.lockedSummary_one": "· {{count}} đã khóa", + "accessRule.lockedSummary_other": "· {{count}} đã khóa", + "accessRule.maintainer": "Người bảo trì", + "accessRule.member": "Thành viên", + "accessRule.newPermissionSet": "Bộ quyền mới", + "accessRule.noAvailableMembers": "Không có thành viên nào để thêm", + "accessRule.noDescription": "Không có mô tả", + "accessRule.noRoles": "Không có vai trò", + "accessRule.noRules": "Không có quy tắc truy cập", + "accessRule.noUserAccessSettings": "Không có cài đặt quyền riêng lẻ", + "accessRule.permission": "Quyền", + "accessRule.resourceOpenScope": "Phạm vi mở của tài nguyên", + "accessRule.resourceOpenScopeDescription": "Chọn tài nguyên này được mở cho ai. Quyền vai trò vẫn quyết định mỗi thành viên có thể làm gì.", + "accessRule.specificMembersOnly": "Chỉ các thành viên cụ thể", + "accessRule.specificMembersOnlyDescription": "Chỉ các thành viên được chọn mới có thể truy cập tài nguyên này.", + "accessRule.summary_one": "{{count}} bộ quyền", + "accessRule.summary_other": "{{count}} bộ quyền", + "accessRule.updated": "Đã cập nhật quy tắc truy cập thành công", + "common.duplicateAction": "Nhân bản", + "group.app": "Ứng dụng", + "group.app_acl": "Quyền truy cập ứng dụng", + "group.billing": "Thanh toán", + "group.credential": "Thông tin xác thực", + "group.dataset": "Cơ sở tri thức", + "group.dataset_acl": "Quyền truy cập cơ sở tri thức", + "group.integration": "Tích hợp", + "group.plugin": "Plugin", + "group.tool_mcp": "Công cụ và MCP", + "group.workspace": "Không gian làm việc", + "permissionList.clearAll": "Xóa tất cả", + "permissionList.collapseGroup": "Thu gọn nhóm", + "permissionList.expandGroup": "Mở rộng nhóm", + "permissionList.noPermissionsFound": "Không tìm thấy quyền nào", + "permissionList.selectAll": "Chọn tất cả", + "permissionSet.descriptionLabel": "Mô tả", + "permissionSet.descriptionPlaceholder": "Mô tả những gì bộ quyền này cấp", + "permissionSet.learnMore": "Tìm hiểu thêm về quyền", + "permissionSet.modal.create.app.description": "Tạo một bộ quyền ứng dụng có thể được tham chiếu trong các quy tắc truy cập để ủy quyền nhanh chóng.", + "permissionSet.modal.create.app.title": "Tạo bộ quyền ứng dụng", + "permissionSet.modal.create.dataset.description": "Tạo một bộ quyền cơ sở tri thức có thể được tham chiếu trong các quy tắc truy cập để ủy quyền nhanh chóng.", + "permissionSet.modal.create.dataset.title": "Tạo bộ quyền cơ sở tri thức", + "permissionSet.modal.edit.app.description": "Sửa đổi tên, mô tả và các quyền được cấp cho bộ quyền này.", + "permissionSet.modal.edit.app.title": "Chỉnh sửa bộ quyền ứng dụng", + "permissionSet.modal.edit.dataset.description": "Sửa đổi tên, mô tả và các quyền được cấp cho bộ quyền này.", + "permissionSet.modal.edit.dataset.title": "Chỉnh sửa bộ quyền cơ sở tri thức", + "permissionSet.modal.view.app.description": "Xem tên, mô tả và các quyền được cấp cho bộ quyền này.", + "permissionSet.modal.view.app.title": "Xem bộ quyền ứng dụng", + "permissionSet.modal.view.dataset.description": "Xem tên, mô tả và các quyền được cấp cho bộ quyền này.", + "permissionSet.modal.view.dataset.title": "Xem bộ quyền cơ sở tri thức", + "permissionSet.nameLabel": "Tên bộ quyền", + "permissionSet.namePlaceholder": "ví dụ: Có thể xuất DSL", + "permissionSet.permissions": "Quyền", + "role.addRole": "Tạo vai trò", + "role.copyMembersDescription_one": "\"{{name}}\" được gán cho {{count}} thành viên. Bạn có muốn bản sao vai trò mới bao gồm cùng thành viên đó không?", + "role.copyMembersDescription_other": "\"{{name}}\" được gán cho {{count}} thành viên. Bạn có muốn bản sao vai trò mới bao gồm cùng các thành viên đó không?", + "role.copyMembersLoading": "Đang tải các phân công thành viên...", + "role.copyMembersTitle": "Sao chép các phân công thành viên?", + "role.created": "Đã tạo vai trò thành công", + "role.deleteDescription": "Vai trò này sẽ bị xóa vĩnh viễn và bị loại bỏ khỏi bất kỳ thành viên hoặc quy tắc truy cập nào sử dụng nó.", + "role.deleteTitle": "Xóa \"{{name}}\"?", + "role.deleted": "Đã xóa vai trò thành công", + "role.duplicated": "Đã nhân bản vai trò thành công", + "role.groups.builtin": "Vai trò hệ thống", + "role.groups.custom": "Vai trò tùy chỉnh", + "role.loading": "Đang tải vai trò...", + "role.modal.create.description": "Tạo một vai trò và gán quyền", + "role.modal.create.title": "Tạo vai trò", + "role.modal.descriptionLabel": "Mô tả", + "role.modal.descriptionPlaceholder": "Mô tả vai trò này chịu trách nhiệm gì", + "role.modal.edit.description": "Chỉnh sửa chi tiết và quyền của vai trò", + "role.modal.edit.title": "Chỉnh sửa vai trò", + "role.modal.nameLabel": "Tên vai trò", + "role.modal.namePlaceholder": "ví dụ: Trưởng nhóm Marketing", + "role.modal.view.description": "Xem chi tiết và quyền của vai trò", + "role.modal.view.title": "Xem vai trò", + "role.noDescription": "Không có mô tả", + "role.noMatchingRoles": "Không có vai trò phù hợp", + "role.searchPlaceholder": "Tìm kiếm vai trò...", + "role.updated": "Đã cập nhật vai trò thành công", + "role.workspaceRoles.description": "Tạo vai trò và xác định những gì mỗi vai trò có thể làm trong không gian làm việc này.", + "role.workspaceRoles.title": "Vai trò không gian làm việc" +} From d8ed874dc7be8766cf599f586f73b0271ad47e35 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 23 Jun 2026 20:50:16 +0800 Subject: [PATCH 3/9] refactor(web): manage deployment async state with atoms (#37819) --- .../skills/how-to-write-component/SKILL.md | 10 +- .../state/__tests__/index.spec.ts | 28 +++++ .../deployments/create-guide/state/index.ts | 78 ++++++------ .../detail/__tests__/state.spec.ts | 18 +++ .../deploy-tab/deployment-row-actions.tsx | 20 ++- .../__tests__/api-key-generate-menu.spec.tsx | 18 ++- .../__tests__/channels-section.spec.tsx | 17 ++- .../access/__tests__/permissions.spec.tsx | 22 ++-- .../access/__tests__/state.spec.ts | 118 ++++++++++++++++++ .../access/api-key-generate-menu.tsx | 6 +- .../settings-tab/access/api-key-list.tsx | 9 +- .../settings-tab/access/channels-section.tsx | 6 +- .../access/developer-api-section.tsx | 6 +- .../settings-tab/access/permissions.tsx | 9 +- .../detail/settings-tab/access/state.ts | 22 +++- web/features/deployments/detail/state.ts | 8 +- .../__tests__/deploy-release-menu.spec.tsx | 24 ++-- .../versions-tab/__tests__/state.spec.ts | 69 ++++++++++ .../versions-tab/deploy-release-menu.tsx | 43 +++---- .../versions-tab/edit-release-dialog.tsx | 6 +- .../deployments/detail/versions-tab/state.ts | 28 ++++- 21 files changed, 415 insertions(+), 150 deletions(-) create mode 100644 web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 8a480c8fd09..e3cd59f810f 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -44,14 +44,16 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## Feature-Scoped Jotai State - A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration. -- Keep state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, row-local pending flags, and in-flight refs usually belong in component state. +- Keep synchronous UI state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, and selected local options usually belong in component state. +- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. This includes remote calls and local async work such as `File.text()`, export/download preparation, parsing, and validation calls. Model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. +- Row-local async state should still come from query or mutation atoms. When a repeated row needs isolated pending/error state, create a per-instance atom with a small factory and memoize it at the row boundary. Keep only synchronous row-local UI state, such as menu open, dialog open, drafts, and selected options, in local component state. - Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface. -- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock state local to that row, menu, or dialog. +- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock scope local to that row, menu, or dialog through a per-instance query/mutation atom; keep only synchronous UI locks in local component state. - Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. - Derived atom names read as business facts. Write atom names read as user or workflow commands. - UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. - Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. -- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, or stale-result concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them. +- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, stale-result, or in-flight concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them. - Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need. - When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks. - `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. @@ -110,7 +112,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`. - For generated oRPC `queryOptions()` / `infiniteOptions()`, keep returning the generated options directly. When required input is missing, use a whole-input branch such as `input: condition ? validInput : skipToken` together with `enabled: Boolean(condition)` so no request runs and no fake payload is built. - Do not put `skipToken` inside a nested placeholder payload, such as `{ params: { appInstanceId: skipToken } }`. Do not create hand-written "missing queryOptions" objects or coerce required IDs to `''`. -- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows. +- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` only in independent non-Jotai component surfaces. In Jotai-backed feature surfaces, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom; use oRPC clients as `mutationFn` only for custom flows. - Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. - Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state. - Do not use deprecated `useInvalid` or `useReset`. diff --git a/web/features/deployments/create-guide/state/__tests__/index.spec.ts b/web/features/deployments/create-guide/state/__tests__/index.spec.ts index f06773b2367..bd674b9c126 100644 --- a/web/features/deployments/create-guide/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-guide/state/__tests__/index.spec.ts @@ -123,6 +123,14 @@ async function loadState() { return await import('../index') } +function workflowDsl() { + return [ + 'app:', + ' mode: workflow', + ' name: Imported guide', + ].join('\n') +} + describe('create deployment guide state', () => { beforeEach(() => { vi.clearAllMocks() @@ -215,4 +223,24 @@ describe('create deployment guide state', () => { expect(store.get(state.releaseNameAtom)).toBe('Initial Release') expect(store.get(state.stepAtom)).toBe('release') }) + + it('should read selected DSL file content through the file content query', async () => { + const state = await loadState() + const store = createStore() + const text = vi.fn().mockResolvedValue(workflowDsl()) + const file = new File([], 'workflow.yml', { type: 'text/yaml' }) + Object.defineProperty(file, 'text', { value: text }) + + store.set(state.selectDslFileAtom, file) + mockQueryResults.current.set('createGuideDslFileContent', { + data: workflowDsl(), + isSuccess: true, + }) + + expect(text).not.toHaveBeenCalled() + expect(store.get(state.dslFileAtom)).toBe(file) + expect(store.get(state.dslDefaultAppNameAtom)).toBe('Imported guide') + expect(store.get(state.isReadingDslAtom)).toBe(false) + expect(store.get(state.dslReadErrorAtom)).toBe(false) + }) }) diff --git a/web/features/deployments/create-guide/state/index.ts b/web/features/deployments/create-guide/state/index.ts index 1db093e9486..f5665e95846 100644 --- a/web/features/deployments/create-guide/state/index.ts +++ b/web/features/deployments/create-guide/state/index.ts @@ -10,7 +10,7 @@ import type { RuntimeCredentialBindingSelections } from '@/features/deployments/ import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error' import type { App } from '@/types/app' import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen' -import { keepPreviousData, skipToken } from '@tanstack/react-query' +import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/components/env-var-bindings-utils' @@ -139,10 +139,41 @@ export const selectedAppAtom = atom(undefined) // DSL primitives and derived state export const dslFileAtom = atom(undefined) -const dslContentAtom = atom('') -export const isReadingDslAtom = atom(false) -export const dslReadErrorAtom = atom(false) -const dslReadTokenAtom = atom(0) +const dslFileReadVersionAtom = atom(0) + +const dslFileContentQueryAtom = atomWithQuery((get) => { + const file = get(dslFileAtom) + const fileReadVersion = get(dslFileReadVersionAtom) + + return queryOptions({ + queryKey: [ + 'createGuideDslFileContent', + fileReadVersion, + file, + file?.name ?? '', + file?.size ?? 0, + file?.lastModified ?? 0, + ], + queryFn: async () => file ? await file.text() : '', + enabled: Boolean(file), + retry: false, + }) +}) + +const dslContentAtom = atom((get) => { + return get(dslFileContentQueryAtom).data ?? '' +}) + +export const isReadingDslAtom = atom((get) => { + const file = get(dslFileAtom) + const dslFileContentQuery = get(dslFileContentQueryAtom) + + return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching)) +}) + +export const dslReadErrorAtom = atom((get) => { + return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError) +}) export const dslDefaultAppNameAtom = atom((get) => { const dslContent = get(dslContentAtom) @@ -470,42 +501,14 @@ export const continueFromSourceAtom = atom(null, (get, set, { }) // DSL actions -export const selectDslFileAtom = atom(null, async (get, set, dslFile?: File) => { +export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => { set(selectedEnvironmentIdAtom, '') set(manualBindingSelectionsAtom, {}) set(envVarValuesAtom, {}) set(submissionUnsupportedDslNodesAtom, []) - // Token guard prevents a slow read from an older file from overwriting the newest selection. - const dslReadToken = get(dslReadTokenAtom) + 1 - set(dslReadTokenAtom, dslReadToken) + set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1) set(dslFileAtom, dslFile) - set(dslContentAtom, '') - set(isReadingDslAtom, Boolean(dslFile)) - set(dslReadErrorAtom, false) - - if (!dslFile) - return - - try { - const content = await dslFile.text() - if (get(dslReadTokenAtom) !== dslReadToken) - return - - set(dslContentAtom, content) - set(dslReadErrorAtom, false) - } - catch { - if (get(dslReadTokenAtom) !== dslReadToken) - return - - set(dslContentAtom, '') - set(dslReadErrorAtom, true) - } - finally { - if (get(dslReadTokenAtom) === dslReadToken) - set(isReadingDslAtom, false) - } }) // Release derived state and actions @@ -914,10 +917,7 @@ export const createDeploymentGuideScopedAtoms = [ sourceSearchTextAtom, selectedAppAtom, dslFileAtom, - dslContentAtom, - isReadingDslAtom, - dslReadErrorAtom, - dslReadTokenAtom, + dslFileReadVersionAtom, instanceNameAtom, instanceDescriptionAtom, releaseNameAtom, diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 1a53b843c4c..81e562dd7d3 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -11,6 +11,10 @@ type QueryOptions = { refetchInterval?: (query: { state: { data?: unknown } }) => number | false } +type MutationOptions = { + mutationKey?: readonly unknown[] +} + vi.mock('jotai-tanstack-query', () => ({ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ ...createOptions(get), @@ -20,6 +24,7 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), + atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ @@ -56,6 +61,9 @@ vi.mock('@/service/client', () => ({ queryKey: ['listEnvironmentDeployments', options.input], }), }, + undeploy: { + mutationOptions: () => ({ mutationKey: ['undeploy'] }), + }, }, }, }, @@ -123,4 +131,14 @@ describe('deployment detail state', () => { input: { params: { app_id: 'source-app-1' } }, }) }) + + it('should expose deployment row mutations from state', async () => { + const state = await loadState() + const store = createStore() + const undeployDeploymentMutationAtom = state.createUndeployDeploymentMutationAtom() + + expect(store.get(undeployDeploymentMutationAtom)).toMatchObject({ + mutationKey: ['undeploy'], + }) + }) }) diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx index 8e3fa0c55a3..2542076bd26 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx @@ -2,14 +2,13 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen' import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' -import { useMutation } from '@tanstack/react-query' -import { useSetAtom } from 'jotai' -import { useRef, useState } from 'react' +import { useAtomValue, useSetAtom } from 'jotai' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' import { openDeployDrawerAtom } from '../../deploy-drawer/state' import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency' import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' +import { createUndeployDeploymentMutationAtom } from '../state' import { DeploymentErrorDialog } from './deployment-error-dialog' import { DeploymentActionsDropdown } from './deployment-row-actions-menu' import { UndeployDeploymentDialog } from './undeploy-deployment-dialog' @@ -21,14 +20,13 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: { }) { const { t } = useTranslation('deployments') const openDeployDrawer = useSetAtom(openDeployDrawerAtom) - const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions()) + const undeployDeploymentMutationAtom = useMemo(() => createUndeployDeploymentMutationAtom(), []) + const undeployDeployment = useAtomValue(undeployDeploymentMutationAtom) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) const [showErrorDetail, setShowErrorDetail] = useState(false) - const [isUndeploying, setIsUndeploying] = useState(false) - const undeployInFlightRef = useRef(false) const isUndeployed = isUndeployedDeploymentRow(row) const status = row.status - const isUndeployRequesting = undeployDeployment.isPending || isUndeploying + const isUndeployRequesting = undeployDeployment.isPending const undeployActionDisabled = isUndeployRequesting const isDeploymentInProgress = isRuntimeDeploymentInProgress(status) const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED @@ -43,11 +41,9 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: { } function handleUndeploy() { - if (undeployInFlightRef.current) + if (isUndeployRequesting) return - undeployInFlightRef.current = true - setIsUndeploying(true) undeployDeployment.mutate( { params: { appInstanceId, environmentId: envId }, @@ -59,8 +55,6 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: { }, { onSettled: () => { - undeployInFlightRef.current = false - setIsUndeploying(false) setShowUndeployConfirm(false) }, }, diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx index 9dceb9e2aa8..ee8ef010f69 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx @@ -3,14 +3,16 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { ApiKeyGenerateMenu } from '../api-key-generate-menu' -const mockMutate = vi.fn() -const mockUseMutation = vi.hoisted(() => vi.fn()) +const mockMutate = vi.hoisted(() => vi.fn()) + +vi.mock('../state', async () => { + const { atom } = await import('jotai') -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), + createApiKeyMutationAtom: atom({ + isPending: false, + mutate: mockMutate, + }), } }) @@ -24,10 +26,6 @@ function createEnvironment(): Environment { describe('ApiKeyGenerateMenu', () => { beforeEach(() => { vi.clearAllMocks() - mockUseMutation.mockReturnValue({ - isPending: false, - mutate: mockMutate, - }) }) it('should show the required name error when submitting an empty name', () => { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx index 730380b4284..07863d5a74e 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx @@ -3,13 +3,16 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AccessChannelsSection } from '../channels-section' -const mockUseMutation = vi.hoisted(() => vi.fn()) +const mockToggleAccessChannel = vi.hoisted(() => vi.fn()) + +vi.mock('../state', async () => { + const { atom } = await import('jotai') -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), + updateAccessChannelsMutationAtom: atom({ + isPending: false, + mutate: mockToggleAccessChannel, + }), } }) @@ -38,10 +41,6 @@ function createEndpoint(endpointUrl: string): AccessEndpoint { describe('AccessChannelsSection', () => { beforeEach(() => { vi.clearAllMocks() - mockUseMutation.mockReturnValue({ - isPending: false, - mutate: vi.fn(), - }) }) it('should render channel descriptions when access channels are enabled', () => { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx index 0f8be629b27..ebf93f1b28f 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx @@ -7,14 +7,16 @@ import { describe, expect, it, vi } from 'vitest' import { EnvironmentPermissionRow } from '../permissions' import { AccessPermissionsSection } from '../permissions-section' -const mockMutate = vi.fn() -const mockUseMutation = vi.hoisted(() => vi.fn()) +const mockMutate = vi.hoisted(() => vi.fn()) + +vi.mock('../state', async () => { + const { atom } = await import('jotai') -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), + createUpdateAccessPolicyMutationAtom: () => atom({ + isPending: false, + mutate: mockMutate, + }), } }) @@ -77,10 +79,6 @@ describe('EnvironmentPermissionRow', () => { mockMutate.mockImplementation((_variables: unknown, options?: { onError?: () => void }) => { options?.onError?.() }) - mockUseMutation.mockReturnValue({ - isPending: false, - mutate: mockMutate, - }) }) it('should keep the previous permission visible when updating the policy fails', () => { @@ -121,10 +119,6 @@ describe('EnvironmentPermissionRow', () => { describe('AccessPermissionsSection', () => { beforeEach(() => { vi.clearAllMocks() - mockUseMutation.mockReturnValue({ - isPending: false, - mutate: mockMutate, - }) }) it('should render permission rows without column headers', () => { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts new file mode 100644 index 00000000000..02e4d597e75 --- /dev/null +++ b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts @@ -0,0 +1,118 @@ +import type { Getter } from 'jotai' +import { skipToken } from '@tanstack/react-query' +import { atom, createStore } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' + +type QueryOptions = { + enabled?: boolean + input?: unknown + queryKey?: readonly unknown[] +} + +type MutationOptions = { + mutationKey?: readonly unknown[] +} + +vi.mock('jotai-tanstack-query', () => ({ + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), + atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + createApiKey: { + mutationOptions: () => ({ mutationKey: ['createApiKey'] }), + }, + deleteApiKey: { + mutationOptions: () => ({ mutationKey: ['deleteApiKey'] }), + }, + getAccessSettings: { + queryOptions: (options: QueryOptions) => ({ + ...options, + queryKey: ['getAccessSettings', options.input], + }), + }, + getDeveloperApiSettings: { + queryOptions: (options: QueryOptions) => ({ + ...options, + queryKey: ['getDeveloperApiSettings', options.input], + }), + }, + updateAccessChannels: { + mutationOptions: () => ({ mutationKey: ['updateAccessChannels'] }), + }, + updateAccessPolicy: { + mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }), + }, + }, + }, + }, +})) + +async function loadState() { + return await import('../state') +} + +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/settings/access`, + params: { appInstanceId }, + }) +} + +describe('deployment access state', () => { + it('should gate access queries until a route app instance exists', async () => { + const state = await loadState() + const store = createStore() + + expect(store.get(state.accessSettingsQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + + setDeploymentRoute(store) + + expect(store.get(state.accessSettingsQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + }) + + it('should expose access mutation atoms from state', async () => { + const state = await loadState() + const store = createStore() + const deleteApiKeyMutationAtom = state.createDeleteApiKeyMutationAtom() + const updateAccessPolicyMutationAtom = state.createUpdateAccessPolicyMutationAtom() + + expect(store.get(state.updateAccessChannelsMutationAtom)).toMatchObject({ + mutationKey: ['updateAccessChannels'], + }) + expect(store.get(state.createApiKeyMutationAtom)).toMatchObject({ + mutationKey: ['createApiKey'], + }) + expect(store.get(deleteApiKeyMutationAtom)).toMatchObject({ + mutationKey: ['deleteApiKey'], + }) + expect(store.get(updateAccessPolicyMutationAtom)).toMatchObject({ + mutationKey: ['updateAccessPolicy'], + }) + }) +}) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx b/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx index bde6eb5d667..53ec1c114d4 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx +++ b/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx @@ -23,11 +23,11 @@ import { SelectTrigger, } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useEffect, useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' import { generateApiTokenName } from './api-token-name' +import { createApiKeyMutationAtom } from './state' export function ApiKeyGenerateMenu({ appInstanceId, @@ -51,7 +51,7 @@ export function ApiKeyGenerateMenu({ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState() const [draftName, setDraftName] = useState('') const [nameError, setNameError] = useState(false) - const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions()) + const generateApiKey = useAtomValue(createApiKeyMutationAtom) const selectableEnvironments = environments const selectedEnvironment = selectedEnvironmentId ? selectableEnvironments.find(env => env.id === selectedEnvironmentId) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx b/web/features/deployments/detail/settings-tab/access/api-key-list.tsx index cf2a8553aee..369eaf12e8a 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx +++ b/web/features/deployments/detail/settings-tab/access/api-key-list.tsx @@ -15,10 +15,9 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useAtomValue } from 'jotai' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' import { DetailTable, DetailTableBody, @@ -32,6 +31,7 @@ import { import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES, } from '../../table-styles' +import { createDeleteApiKeyMutationAtom } from './state' function ApiKeyName({ apiKey }: { apiKey: ApiKey @@ -70,7 +70,8 @@ function RevokeApiKeyButton({ apiKey }: { }) { const { t } = useTranslation('deployments') const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) - const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions()) + const deleteApiKeyMutationAtom = useMemo(() => createDeleteApiKeyMutationAtom(), []) + const revokeApiKey = useAtomValue(deleteApiKeyMutationAtom) const isRevoking = revokeApiKey.isPending const apiKeyName = apiKey.displayName diff --git a/web/features/deployments/detail/settings-tab/access/channels-section.tsx b/web/features/deployments/detail/settings-tab/access/channels-section.tsx index d3dde7936a4..e1c5fe6cb2e 100644 --- a/web/features/deployments/detail/settings-tab/access/channels-section.tsx +++ b/web/features/deployments/detail/settings-tab/access/channels-section.tsx @@ -3,13 +3,13 @@ import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/types.gen' import type { ReactNode } from 'react' import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch' -import { useMutation } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -import { consoleQuery } from '@/service/client' import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state' import { Section } from '../../common' import { CopyPill, EndpointRow } from './common' +import { updateAccessChannelsMutationAtom } from './state' import { getUrlOrigin } from './url' const ACCESS_CHANNEL_SKELETON_SECTIONS = [ @@ -24,7 +24,7 @@ function AccessChannelsSwitch({ appInstanceId, checked, accessChannels, disabled disabled?: boolean }) { const { t } = useTranslation('deployments') - const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) + const toggleAccessChannel = useAtomValue(updateAccessChannelsMutationAtom) return ( createUpdateAccessPolicyMutationAtom(), []) + const setEnvironmentAccessPolicy = useAtomValue(updateAccessPolicyMutationAtom) const policy = summaryPolicy const policyKind = accessModeToPermissionKey(policy?.mode) const policyFingerprint = policy diff --git a/web/features/deployments/detail/settings-tab/access/state.ts b/web/features/deployments/detail/settings-tab/access/state.ts index 57bf97c9d99..0e67da37b59 100644 --- a/web/features/deployments/detail/settings-tab/access/state.ts +++ b/web/features/deployments/detail/settings-tab/access/state.ts @@ -1,7 +1,7 @@ 'use client' import { skipToken } from '@tanstack/react-query' -import { atomWithQuery } from 'jotai-tanstack-query' +import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' @@ -30,3 +30,23 @@ export const developerApiSettingsQueryAtom = atomWithQuery((get) => { enabled: Boolean(appInstanceId), }) }) + +export const updateAccessChannelsMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions(), +) + +export const createApiKeyMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.accessService.createApiKey.mutationOptions(), +) + +export function createDeleteApiKeyMutationAtom() { + return atomWithMutation(() => + consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions(), + ) +} + +export function createUpdateAccessPolicyMutationAtom() { + return atomWithMutation(() => + consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions(), + ) +} diff --git a/web/features/deployments/detail/state.ts b/web/features/deployments/detail/state.ts index 68bb24ab3a6..d266f55ff5b 100644 --- a/web/features/deployments/detail/state.ts +++ b/web/features/deployments/detail/state.ts @@ -2,7 +2,7 @@ import { skipToken } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithQuery } from 'jotai-tanstack-query' +import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../route-state' import { deploymentStatusPollingInterval } from '../shared/domain/runtime-status' @@ -59,3 +59,9 @@ export const deploymentSourceAppQueryAtom = atomWithQuery((get) => { enabled: Boolean(sourceAppId), }) }) + +export function createUndeployDeploymentMutationAtom() { + return atomWithMutation(() => + consoleQuery.enterprise.deploymentService.undeploy.mutationOptions(), + ) +} diff --git a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx index 88692e55360..80701a10e3b 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx +++ b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx @@ -4,19 +4,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { DeployReleaseMenu } from '../deploy-release-menu' -const mockUseMutation = vi.hoisted(() => vi.fn()) -const mockDeleteRelease = vi.fn() +const mockDeleteRelease = vi.hoisted(() => vi.fn()) +const mockExportReleaseDsl = vi.hoisted(() => vi.fn()) vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu')) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), - } -}) - vi.mock('../state', async (importOriginal) => { const actual = await importOriginal() const { atom } = await import('jotai') @@ -25,6 +17,14 @@ vi.mock('../state', async (importOriginal) => { ...actual, deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()), deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()), + deleteReleaseMutationAtom: atom({ + isPending: false, + mutate: mockDeleteRelease, + }), + exportReleaseDslMutationAtom: atom({ + isPending: false, + mutate: mockExportReleaseDsl, + }), } }) @@ -78,10 +78,6 @@ function appInstanceResult() { describe('DeployReleaseMenu', () => { beforeEach(() => { vi.clearAllMocks() - mockUseMutation.mockReturnValue({ - isPending: false, - mutate: mockDeleteRelease, - }) }) it('should disable release deletion when deployment usage cannot be checked', () => { diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts index 2e58fb3bb80..d5d2fb90c56 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts @@ -1,4 +1,6 @@ +import type { Release } from '@dify/contracts/enterprise/types.gen' import type { Getter } from 'jotai' +import { ReleaseSource } from '@dify/contracts/enterprise/types.gen' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' @@ -11,6 +13,13 @@ type QueryOptions = { queryKey?: readonly unknown[] } +type MutationOptions = { + mutationFn?: (variables: unknown) => Promise + mutationKey?: readonly unknown[] +} + +const mockExportReleaseDsl = vi.hoisted(() => vi.fn()) + vi.mock('jotai-tanstack-query', () => ({ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ ...createOptions(get), @@ -20,6 +29,7 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), + atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ @@ -42,21 +52,48 @@ vi.mock('@/service/client', () => ({ }, }, releaseService: { + deleteRelease: { + mutationOptions: () => ({ mutationKey: ['deleteRelease'] }), + }, listReleaseSummaries: { queryOptions: (options: QueryOptions) => ({ ...options, queryKey: ['listReleaseSummaries', options.input], }), }, + updateRelease: { + mutationOptions: () => ({ mutationKey: ['updateRelease'] }), + }, }, }, }, })) +vi.mock('../release-dsl-export', () => ({ + exportReleaseDsl: (...args: unknown[]) => mockExportReleaseDsl(...args), +})) + async function loadState() { return await import('../state') } +function createRelease(): Release { + return { + id: 'release-1', + appInstanceId: 'app-instance-1', + displayName: 'Release 1', + description: '', + source: ReleaseSource.RELEASE_SOURCE_UPLOAD, + gateCommitId: 'commit-1', + requiredSlots: [], + createdBy: { + id: 'account-1', + displayName: 'Dify Admin', + }, + createdAt: '2026-01-01T00:00:00.000Z', + } +} + function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/overview`, @@ -147,4 +184,36 @@ describe('versions tab state', () => { store.set(state.adjustReleaseHistoryPageAfterDeleteAtom, 1) expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(1) }) + + it('should expose release mutation atoms from state', async () => { + const state = await loadState() + const store = createStore() + + expect(store.get(state.deleteReleaseMutationAtom)).toMatchObject({ + mutationKey: ['deleteRelease'], + }) + expect(store.get(state.updateReleaseMutationAtom)).toMatchObject({ + mutationKey: ['updateRelease'], + }) + }) + + it('should expose release DSL export as a mutation atom', async () => { + const state = await loadState() + const store = createStore() + const mutationOptions = store.get(state.exportReleaseDslMutationAtom) as unknown as MutationOptions + const release = createRelease() + + await mutationOptions.mutationFn?.({ + release, + releaseId: release.id, + appInstanceName: 'Deployment 1', + }) + + expect(mutationOptions.mutationKey).toEqual(['deployments', 'release-dsl-export']) + expect(mockExportReleaseDsl).toHaveBeenCalledWith({ + release, + releaseId: release.id, + appInstanceName: 'Deployment 1', + }) + }) }) diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx index 332ccb2f18a..26a6067c932 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -9,11 +9,9 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' import { TitleTooltip } from '../../components/title-tooltip' import { openDeployDrawerAtom } from '../../deploy-drawer/state' import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' @@ -24,11 +22,12 @@ import { releaseUsageCount, } from './deploy-release-menu-utils' import { EditReleaseDialog } from './edit-release-dialog' -import { exportReleaseDsl } from './release-dsl-export' import { + deleteReleaseMutationAtom, deployReleaseMenuAppInstanceQueryAtom, deployReleaseMenuEnvironmentDeploymentsQueryAtom, deployReleaseMenuOpenReleaseIdAtom, + exportReleaseDslMutationAtom, setDeployReleaseMenuOpenAtom, } from './state' @@ -44,11 +43,11 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom) const [showEditDialog, setShowEditDialog] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const [isExportingDsl, setIsExportingDsl] = useState(false) const open = openReleaseMenuId === releaseId const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom) const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom) - const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions()) + const deleteRelease = useAtomValue(deleteReleaseMutationAtom) + const exportReleaseDslMutation = useAtomValue(exportReleaseDslMutationAtom) const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? []) .map(row => row.environment) @@ -59,12 +58,14 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel if (!targetRelease) return null - const targetReleaseName = targetRelease.displayName + const release = targetRelease + const targetReleaseName = release.displayName const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows) const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading const hasDeleteUsageCheckFailed = open && environmentDeploymentsQuery.isError const isReleaseInUse = deleteUsageCount > 0 const isDeletingRelease = deleteRelease.isPending + const isExportingDsl = exportReleaseDslMutation.isPending const deleteDisabledReason = isCheckingDeleteUsage ? t('versions.disabledReason.checkingDeployments') : hasDeleteUsageCheckFailed @@ -78,21 +79,21 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel setDeployReleaseMenuOpen({ releaseId, open: nextOpen }) } - const handleExportDsl = async () => { + function handleExportDsl() { if (isExportingDsl) return - setIsExportingDsl(true) - try { - await exportReleaseDsl({ release: targetRelease, releaseId, appInstanceName }) - handleOpenChange(false) - } - catch { - toast.error(t('versions.exportDslFailed')) - } - finally { - setIsExportingDsl(false) - } + exportReleaseDslMutation.mutate( + { release, releaseId, appInstanceName }, + { + onSuccess: () => { + handleOpenChange(false) + }, + onError: () => { + toast.error(t('versions.exportDslFailed')) + }, + }, + ) } function handleDeleteRelease() { @@ -123,7 +124,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel environmentDeployments: environmentDeploymentsQuery.data?.environmentDeployments ?? [], releaseRows, releaseId, - targetRelease, + targetRelease: release, t, }) @@ -224,14 +225,14 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel void }) { const { t } = useTranslation('deployments') - const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions()) + const updateRelease = useAtomValue(updateReleaseMutationAtom) const formKey = `${release.id}-${release.displayName}-${release.description}` function handleOpenChange(nextOpen: boolean) { diff --git a/web/features/deployments/detail/versions-tab/state.ts b/web/features/deployments/detail/versions-tab/state.ts index 5cf772bc54f..96b741720a3 100644 --- a/web/features/deployments/detail/versions-tab/state.ts +++ b/web/features/deployments/detail/versions-tab/state.ts @@ -1,12 +1,19 @@ 'use client' -import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen' -import { keepPreviousData, skipToken } from '@tanstack/react-query' +import type { ListReleaseSummariesResponse, Release } from '@dify/contracts/enterprise/types.gen' +import { keepPreviousData, mutationOptions, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithQuery } from 'jotai-tanstack-query' +import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../route-state' import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination' +import { exportReleaseDsl } from './release-dsl-export' + +type ExportReleaseDslInput = { + release: Release + releaseId: string + appInstanceName?: string +} export const releaseHistoryCurrentPageAtom = atom(0) export const deployReleaseMenuOpenReleaseIdAtom = atom(undefined) @@ -58,6 +65,21 @@ export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => { }) }) +export const deleteReleaseMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions(), +) + +export const updateReleaseMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.releaseService.updateRelease.mutationOptions(), +) + +export const exportReleaseDslMutationAtom = atomWithMutation(() => + mutationOptions({ + mutationKey: ['deployments', 'release-dsl-export'], + mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input), + }), +) + export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => { set(releaseHistoryCurrentPageAtom, Math.max(page, 0)) }) From acf6d0ddc978cf78e9a28144730e706bdb47d4b6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 23 Jun 2026 21:20:23 +0800 Subject: [PATCH 4/9] refactor(web): simplify deployment async ownership (#37823) --- .../skills/how-to-write-component/SKILL.md | 15 ++-- .../detail/__tests__/state.spec.ts | 18 ----- .../deploy-tab/deployment-row-actions.tsx | 10 +-- .../__tests__/api-key-generate-menu.spec.tsx | 26 ++++--- .../__tests__/channels-section.spec.tsx | 26 ++++--- .../access/__tests__/permissions.spec.tsx | 26 ++++--- .../access/__tests__/state.spec.ts | 37 ---------- .../access/api-key-generate-menu.tsx | 6 +- .../settings-tab/access/api-key-list.tsx | 9 ++- .../settings-tab/access/channels-section.tsx | 6 +- .../access/developer-api-section.tsx | 6 +- .../settings-tab/access/permissions.tsx | 9 ++- .../detail/settings-tab/access/state.ts | 22 +----- web/features/deployments/detail/state.ts | 8 +-- .../__tests__/deploy-release-menu.spec.tsx | 34 ++++++--- .../versions-tab/__tests__/state.spec.ts | 69 ------------------- .../versions-tab/deploy-release-menu.tsx | 18 +++-- .../versions-tab/edit-release-dialog.tsx | 6 +- .../deployments/detail/versions-tab/state.ts | 28 +------- web/features/deployments/list/state/index.ts | 15 +--- .../list/ui/environment-filter.tsx | 19 +++-- 21 files changed, 144 insertions(+), 269 deletions(-) diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index e3cd59f810f..689cd227b3a 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -36,19 +36,19 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. - Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. - When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. -- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. The lowest-owner rule still applies to independent visual surfaces that do not participate in shared state. +- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. Do not create a query or mutation atom only because the surrounding feature uses Jotai. If the query or mutation does not read atom state, feed another atom, or participate in shared workflow orchestration, use `useQuery` or `useMutation` directly at the lowest owner. - For repeated row/menu action surfaces that need reset, hydrate the stable identity at the surface entry and scope only the primitives that truly need per-instance reset, such as open flags, drafts, or selected local options. - Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. - Prefer uncontrolled DOM state and CSS variables before adding controlled props. ## Feature-Scoped Jotai State -- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration. +- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, shared query atoms, derived atoms, write-only action atoms, shared mutation atoms, submission orchestration, provider exports, and optional scope configuration. - Keep synchronous UI state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, and selected local options usually belong in component state. -- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. This includes remote calls and local async work such as `File.text()`, export/download preparation, parsing, and validation calls. Model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. -- Row-local async state should still come from query or mutation atoms. When a repeated row needs isolated pending/error state, create a per-instance atom with a small factory and memoize it at the row boundary. Keep only synchronous row-local UI state, such as menu open, dialog open, drafts, and selected options, in local component state. +- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. For async work that depends on atom state, feeds derived atoms, or participates in shared submission orchestration, model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. For component-owned remote work that does not participate in atom state, use TanStack Query hooks directly. +- Row-local async state should belong to the row owner. Use `useQuery` or `useMutation` directly for row actions that do not depend on atom state and are not consumed by other atoms. Use a per-instance query or mutation atom only when the row action participates in a Jotai-backed shared workflow or needs atom-scoped reset semantics. - Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface. -- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock scope local to that row, menu, or dialog through a per-instance query/mutation atom; keep only synchronous UI locks in local component state. +- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock scope local to that row, menu, or dialog with the lowest-owner query/mutation hook unless it genuinely participates in shared atom state. - Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. - Derived atom names read as business facts. Write atom names read as user or workflow commands. - UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. @@ -56,7 +56,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, stale-result, or in-flight concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them. - Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need. - When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks. -- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. +- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state only when they need atom inputs, provide data to derived atoms, or coordinate a shared Jotai-backed workflow. - Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query and mutation atoms keep shared cache behavior through the shared QueryClient. - Do not put `atomWithQuery`, `atomWithInfiniteQuery`, `atomWithMutation`, or broad derived orchestration atoms in a `ScopeProvider` just to reset a surface. Scoped derived atoms implicitly scope their dependencies, which can duplicate query client access and break shared invalidation. Leave query/mutation atoms unscoped; let them read scoped primitive inputs. - Scope providers should list resettable primitive atoms and explicit hydration tuples. If a derived atom must be scoped, confirm that every dependency it implicitly scopes is meant to be private to that surface. @@ -104,6 +104,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape. - Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. +- Do not promote a query or mutation to an atom just because the feature already has a state file. Use `atomWithQuery` or `atomWithMutation` only when the query/mutation reads atom state, is consumed by another atom, or is part of shared workflow orchestration. - In `atomWithQuery` and `atomWithInfiniteQuery`, return generated `queryOptions()` or `infiniteOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into that call instead of spreading generated options into a hand-built object. - In `atomWithMutation`, return generated `mutationOptions()` directly when using generated clients. Put request shaping and submit orchestration in write atoms; do not rebuild mutation option objects just to pass through the generated mutation function. - For custom query functions that do not come from generated clients, wrap the options object with TanStack `queryOptions(...)` so query atoms still return a query options contract. @@ -112,7 +113,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`. - For generated oRPC `queryOptions()` / `infiniteOptions()`, keep returning the generated options directly. When required input is missing, use a whole-input branch such as `input: condition ? validInput : skipToken` together with `enabled: Boolean(condition)` so no request runs and no fake payload is built. - Do not put `skipToken` inside a nested placeholder payload, such as `{ params: { appInstanceId: skipToken } }`. Do not create hand-written "missing queryOptions" objects or coerce required IDs to `''`. -- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` only in independent non-Jotai component surfaces. In Jotai-backed feature surfaces, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom; use oRPC clients as `mutationFn` only for custom flows. +- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when the mutation is owned by one component, menu, dialog, or row and its pending/error state is not consumed by feature atoms. In Jotai-backed workflow orchestration, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom. For component-owned custom mutation functions, use `useMutation(mutationOptions(...))` at the owner. - Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. - Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state. - Do not use deprecated `useInvalid` or `useReset`. diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 81e562dd7d3..1a53b843c4c 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -11,10 +11,6 @@ type QueryOptions = { refetchInterval?: (query: { state: { data?: unknown } }) => number | false } -type MutationOptions = { - mutationKey?: readonly unknown[] -} - vi.mock('jotai-tanstack-query', () => ({ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ ...createOptions(get), @@ -24,7 +20,6 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), - atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ @@ -61,9 +56,6 @@ vi.mock('@/service/client', () => ({ queryKey: ['listEnvironmentDeployments', options.input], }), }, - undeploy: { - mutationOptions: () => ({ mutationKey: ['undeploy'] }), - }, }, }, }, @@ -131,14 +123,4 @@ describe('deployment detail state', () => { input: { params: { app_id: 'source-app-1' } }, }) }) - - it('should expose deployment row mutations from state', async () => { - const state = await loadState() - const store = createStore() - const undeployDeploymentMutationAtom = state.createUndeployDeploymentMutationAtom() - - expect(store.get(undeployDeploymentMutationAtom)).toMatchObject({ - mutationKey: ['undeploy'], - }) - }) }) diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx index 2542076bd26..28e67dfd53d 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx @@ -2,13 +2,14 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen' import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' -import { useAtomValue, useSetAtom } from 'jotai' -import { useMemo, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useSetAtom } from 'jotai' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' import { openDeployDrawerAtom } from '../../deploy-drawer/state' import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency' import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' -import { createUndeployDeploymentMutationAtom } from '../state' import { DeploymentErrorDialog } from './deployment-error-dialog' import { DeploymentActionsDropdown } from './deployment-row-actions-menu' import { UndeployDeploymentDialog } from './undeploy-deployment-dialog' @@ -20,8 +21,7 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: { }) { const { t } = useTranslation('deployments') const openDeployDrawer = useSetAtom(openDeployDrawerAtom) - const undeployDeploymentMutationAtom = useMemo(() => createUndeployDeploymentMutationAtom(), []) - const undeployDeployment = useAtomValue(undeployDeploymentMutationAtom) + const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions()) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) const [showErrorDetail, setShowErrorDetail] = useState(false) const isUndeployed = isUndeployedDeploymentRow(row) diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx index ee8ef010f69..a83974fba6f 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx @@ -5,16 +5,24 @@ import { ApiKeyGenerateMenu } from '../api-key-generate-menu' const mockMutate = vi.hoisted(() => vi.fn()) -vi.mock('../state', async () => { - const { atom } = await import('jotai') +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + isPending: false, + mutate: mockMutate, + }), +})) - return { - createApiKeyMutationAtom: atom({ - isPending: false, - mutate: mockMutate, - }), - } -}) +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + createApiKey: { + mutationOptions: () => ({ mutationKey: ['createApiKey'] }), + }, + }, + }, + }, +})) function createEnvironment(): Environment { return { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx index 07863d5a74e..fb4195115a7 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx @@ -5,16 +5,24 @@ import { AccessChannelsSection } from '../channels-section' const mockToggleAccessChannel = vi.hoisted(() => vi.fn()) -vi.mock('../state', async () => { - const { atom } = await import('jotai') +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + isPending: false, + mutate: mockToggleAccessChannel, + }), +})) - return { - updateAccessChannelsMutationAtom: atom({ - isPending: false, - mutate: mockToggleAccessChannel, - }), - } -}) +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + updateAccessChannels: { + mutationOptions: () => ({ mutationKey: ['updateAccessChannels'] }), + }, + }, + }, + }, +})) function createAccessChannels(): AccessChannels { return { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx index ebf93f1b28f..891f117efea 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx @@ -9,16 +9,24 @@ import { AccessPermissionsSection } from '../permissions-section' const mockMutate = vi.hoisted(() => vi.fn()) -vi.mock('../state', async () => { - const { atom } = await import('jotai') +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + isPending: false, + mutate: mockMutate, + }), +})) - return { - createUpdateAccessPolicyMutationAtom: () => atom({ - isPending: false, - mutate: mockMutate, - }), - } -}) +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + updateAccessPolicy: { + mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }), + }, + }, + }, + }, +})) function renderWithAtomStore(children: ReactNode) { return render( diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts index 02e4d597e75..77e3d9b2c22 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts +++ b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts @@ -10,10 +10,6 @@ type QueryOptions = { queryKey?: readonly unknown[] } -type MutationOptions = { - mutationKey?: readonly unknown[] -} - vi.mock('jotai-tanstack-query', () => ({ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ ...createOptions(get), @@ -23,19 +19,12 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), - atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ consoleQuery: { enterprise: { accessService: { - createApiKey: { - mutationOptions: () => ({ mutationKey: ['createApiKey'] }), - }, - deleteApiKey: { - mutationOptions: () => ({ mutationKey: ['deleteApiKey'] }), - }, getAccessSettings: { queryOptions: (options: QueryOptions) => ({ ...options, @@ -48,12 +37,6 @@ vi.mock('@/service/client', () => ({ queryKey: ['getDeveloperApiSettings', options.input], }), }, - updateAccessChannels: { - mutationOptions: () => ({ mutationKey: ['updateAccessChannels'] }), - }, - updateAccessPolicy: { - mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }), - }, }, }, }, @@ -95,24 +78,4 @@ describe('deployment access state', () => { input: { params: { appInstanceId: 'app-instance-1' } }, }) }) - - it('should expose access mutation atoms from state', async () => { - const state = await loadState() - const store = createStore() - const deleteApiKeyMutationAtom = state.createDeleteApiKeyMutationAtom() - const updateAccessPolicyMutationAtom = state.createUpdateAccessPolicyMutationAtom() - - expect(store.get(state.updateAccessChannelsMutationAtom)).toMatchObject({ - mutationKey: ['updateAccessChannels'], - }) - expect(store.get(state.createApiKeyMutationAtom)).toMatchObject({ - mutationKey: ['createApiKey'], - }) - expect(store.get(deleteApiKeyMutationAtom)).toMatchObject({ - mutationKey: ['deleteApiKey'], - }) - expect(store.get(updateAccessPolicyMutationAtom)).toMatchObject({ - mutationKey: ['updateAccessPolicy'], - }) - }) }) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx b/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx index 53ec1c114d4..bde6eb5d667 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx +++ b/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx @@ -23,11 +23,11 @@ import { SelectTrigger, } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' -import { useAtomValue } from 'jotai' +import { useMutation } from '@tanstack/react-query' import { useEffect, useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' import { generateApiTokenName } from './api-token-name' -import { createApiKeyMutationAtom } from './state' export function ApiKeyGenerateMenu({ appInstanceId, @@ -51,7 +51,7 @@ export function ApiKeyGenerateMenu({ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState() const [draftName, setDraftName] = useState('') const [nameError, setNameError] = useState(false) - const generateApiKey = useAtomValue(createApiKeyMutationAtom) + const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions()) const selectableEnvironments = environments const selectedEnvironment = selectedEnvironmentId ? selectableEnvironments.find(env => env.id === selectedEnvironmentId) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx b/web/features/deployments/detail/settings-tab/access/api-key-list.tsx index 369eaf12e8a..cf2a8553aee 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx +++ b/web/features/deployments/detail/settings-tab/access/api-key-list.tsx @@ -15,9 +15,10 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { useAtomValue } from 'jotai' -import { useMemo, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' import { DetailTable, DetailTableBody, @@ -31,7 +32,6 @@ import { import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES, } from '../../table-styles' -import { createDeleteApiKeyMutationAtom } from './state' function ApiKeyName({ apiKey }: { apiKey: ApiKey @@ -70,8 +70,7 @@ function RevokeApiKeyButton({ apiKey }: { }) { const { t } = useTranslation('deployments') const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) - const deleteApiKeyMutationAtom = useMemo(() => createDeleteApiKeyMutationAtom(), []) - const revokeApiKey = useAtomValue(deleteApiKeyMutationAtom) + const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions()) const isRevoking = revokeApiKey.isPending const apiKeyName = apiKey.displayName diff --git a/web/features/deployments/detail/settings-tab/access/channels-section.tsx b/web/features/deployments/detail/settings-tab/access/channels-section.tsx index e1c5fe6cb2e..d3dde7936a4 100644 --- a/web/features/deployments/detail/settings-tab/access/channels-section.tsx +++ b/web/features/deployments/detail/settings-tab/access/channels-section.tsx @@ -3,13 +3,13 @@ import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/types.gen' import type { ReactNode } from 'react' import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch' -import { useAtomValue } from 'jotai' +import { useMutation } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { consoleQuery } from '@/service/client' import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state' import { Section } from '../../common' import { CopyPill, EndpointRow } from './common' -import { updateAccessChannelsMutationAtom } from './state' import { getUrlOrigin } from './url' const ACCESS_CHANNEL_SKELETON_SECTIONS = [ @@ -24,7 +24,7 @@ function AccessChannelsSwitch({ appInstanceId, checked, accessChannels, disabled disabled?: boolean }) { const { t } = useTranslation('deployments') - const toggleAccessChannel = useAtomValue(updateAccessChannelsMutationAtom) + const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) return ( createUpdateAccessPolicyMutationAtom(), []) - const setEnvironmentAccessPolicy = useAtomValue(updateAccessPolicyMutationAtom) + const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions()) const policy = summaryPolicy const policyKind = accessModeToPermissionKey(policy?.mode) const policyFingerprint = policy diff --git a/web/features/deployments/detail/settings-tab/access/state.ts b/web/features/deployments/detail/settings-tab/access/state.ts index 0e67da37b59..57bf97c9d99 100644 --- a/web/features/deployments/detail/settings-tab/access/state.ts +++ b/web/features/deployments/detail/settings-tab/access/state.ts @@ -1,7 +1,7 @@ 'use client' import { skipToken } from '@tanstack/react-query' -import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' @@ -30,23 +30,3 @@ export const developerApiSettingsQueryAtom = atomWithQuery((get) => { enabled: Boolean(appInstanceId), }) }) - -export const updateAccessChannelsMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions(), -) - -export const createApiKeyMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.accessService.createApiKey.mutationOptions(), -) - -export function createDeleteApiKeyMutationAtom() { - return atomWithMutation(() => - consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions(), - ) -} - -export function createUpdateAccessPolicyMutationAtom() { - return atomWithMutation(() => - consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions(), - ) -} diff --git a/web/features/deployments/detail/state.ts b/web/features/deployments/detail/state.ts index d266f55ff5b..68bb24ab3a6 100644 --- a/web/features/deployments/detail/state.ts +++ b/web/features/deployments/detail/state.ts @@ -2,7 +2,7 @@ import { skipToken } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../route-state' import { deploymentStatusPollingInterval } from '../shared/domain/runtime-status' @@ -59,9 +59,3 @@ export const deploymentSourceAppQueryAtom = atomWithQuery((get) => { enabled: Boolean(sourceAppId), }) }) - -export function createUndeployDeploymentMutationAtom() { - return atomWithMutation(() => - consoleQuery.enterprise.deploymentService.undeploy.mutationOptions(), - ) -} diff --git a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx index 80701a10e3b..34bd8e0f3ea 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx +++ b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx @@ -9,6 +9,32 @@ const mockExportReleaseDsl = vi.hoisted(() => vi.fn()) vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu')) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useMutation: (options: { mutationKey?: readonly unknown[] }) => { + if (options.mutationKey?.[0] === 'deployments') + return { isPending: false, mutate: mockExportReleaseDsl } + + return { isPending: false, mutate: mockDeleteRelease } + }, + } +}) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + releaseService: { + deleteRelease: { + mutationOptions: () => ({ mutationKey: ['deleteRelease'] }), + }, + }, + }, + }, +})) + vi.mock('../state', async (importOriginal) => { const actual = await importOriginal() const { atom } = await import('jotai') @@ -17,14 +43,6 @@ vi.mock('../state', async (importOriginal) => { ...actual, deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()), deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()), - deleteReleaseMutationAtom: atom({ - isPending: false, - mutate: mockDeleteRelease, - }), - exportReleaseDslMutationAtom: atom({ - isPending: false, - mutate: mockExportReleaseDsl, - }), } }) diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts index d5d2fb90c56..2e58fb3bb80 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts @@ -1,6 +1,4 @@ -import type { Release } from '@dify/contracts/enterprise/types.gen' import type { Getter } from 'jotai' -import { ReleaseSource } from '@dify/contracts/enterprise/types.gen' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' @@ -13,13 +11,6 @@ type QueryOptions = { queryKey?: readonly unknown[] } -type MutationOptions = { - mutationFn?: (variables: unknown) => Promise - mutationKey?: readonly unknown[] -} - -const mockExportReleaseDsl = vi.hoisted(() => vi.fn()) - vi.mock('jotai-tanstack-query', () => ({ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ ...createOptions(get), @@ -29,7 +20,6 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), - atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ @@ -52,48 +42,21 @@ vi.mock('@/service/client', () => ({ }, }, releaseService: { - deleteRelease: { - mutationOptions: () => ({ mutationKey: ['deleteRelease'] }), - }, listReleaseSummaries: { queryOptions: (options: QueryOptions) => ({ ...options, queryKey: ['listReleaseSummaries', options.input], }), }, - updateRelease: { - mutationOptions: () => ({ mutationKey: ['updateRelease'] }), - }, }, }, }, })) -vi.mock('../release-dsl-export', () => ({ - exportReleaseDsl: (...args: unknown[]) => mockExportReleaseDsl(...args), -})) - async function loadState() { return await import('../state') } -function createRelease(): Release { - return { - id: 'release-1', - appInstanceId: 'app-instance-1', - displayName: 'Release 1', - description: '', - source: ReleaseSource.RELEASE_SOURCE_UPLOAD, - gateCommitId: 'commit-1', - requiredSlots: [], - createdBy: { - id: 'account-1', - displayName: 'Dify Admin', - }, - createdAt: '2026-01-01T00:00:00.000Z', - } -} - function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/overview`, @@ -184,36 +147,4 @@ describe('versions tab state', () => { store.set(state.adjustReleaseHistoryPageAfterDeleteAtom, 1) expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(1) }) - - it('should expose release mutation atoms from state', async () => { - const state = await loadState() - const store = createStore() - - expect(store.get(state.deleteReleaseMutationAtom)).toMatchObject({ - mutationKey: ['deleteRelease'], - }) - expect(store.get(state.updateReleaseMutationAtom)).toMatchObject({ - mutationKey: ['updateRelease'], - }) - }) - - it('should expose release DSL export as a mutation atom', async () => { - const state = await loadState() - const store = createStore() - const mutationOptions = store.get(state.exportReleaseDslMutationAtom) as unknown as MutationOptions - const release = createRelease() - - await mutationOptions.mutationFn?.({ - release, - releaseId: release.id, - appInstanceName: 'Deployment 1', - }) - - expect(mutationOptions.mutationKey).toEqual(['deployments', 'release-dsl-export']) - expect(mockExportReleaseDsl).toHaveBeenCalledWith({ - release, - releaseId: release.id, - appInstanceName: 'Deployment 1', - }) - }) }) diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx index 26a6067c932..01f9b354447 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -9,9 +9,11 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { mutationOptions, useMutation } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' import { TitleTooltip } from '../../components/title-tooltip' import { openDeployDrawerAtom } from '../../deploy-drawer/state' import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' @@ -22,15 +24,20 @@ import { releaseUsageCount, } from './deploy-release-menu-utils' import { EditReleaseDialog } from './edit-release-dialog' +import { exportReleaseDsl } from './release-dsl-export' import { - deleteReleaseMutationAtom, deployReleaseMenuAppInstanceQueryAtom, deployReleaseMenuEnvironmentDeploymentsQueryAtom, deployReleaseMenuOpenReleaseIdAtom, - exportReleaseDslMutationAtom, setDeployReleaseMenuOpenAtom, } from './state' +type ExportReleaseDslInput = { + release: Release + releaseId: string + appInstanceName?: string +} + export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDeleted }: { appInstanceId: string releaseId: string @@ -46,8 +53,11 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel const open = openReleaseMenuId === releaseId const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom) const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom) - const deleteRelease = useAtomValue(deleteReleaseMutationAtom) - const exportReleaseDslMutation = useAtomValue(exportReleaseDslMutationAtom) + const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions()) + const exportReleaseDslMutation = useMutation(mutationOptions({ + mutationKey: ['deployments', 'release-dsl-export'], + mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input), + })) const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? []) .map(row => row.environment) diff --git a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx b/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx index 899def54ccd..28ca0b6ff1d 100644 --- a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx +++ b/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx @@ -13,10 +13,10 @@ import { import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' -import { useAtomValue } from 'jotai' +import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { updateReleaseMutationAtom } from './state' +import { consoleQuery } from '@/service/client' type EditReleaseFormValues = { name: string @@ -127,7 +127,7 @@ export function EditReleaseDialog({ onOpenChange: (open: boolean) => void }) { const { t } = useTranslation('deployments') - const updateRelease = useAtomValue(updateReleaseMutationAtom) + const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions()) const formKey = `${release.id}-${release.displayName}-${release.description}` function handleOpenChange(nextOpen: boolean) { diff --git a/web/features/deployments/detail/versions-tab/state.ts b/web/features/deployments/detail/versions-tab/state.ts index 96b741720a3..5cf772bc54f 100644 --- a/web/features/deployments/detail/versions-tab/state.ts +++ b/web/features/deployments/detail/versions-tab/state.ts @@ -1,19 +1,12 @@ 'use client' -import type { ListReleaseSummariesResponse, Release } from '@dify/contracts/enterprise/types.gen' -import { keepPreviousData, mutationOptions, skipToken } from '@tanstack/react-query' +import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen' +import { keepPreviousData, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../route-state' import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination' -import { exportReleaseDsl } from './release-dsl-export' - -type ExportReleaseDslInput = { - release: Release - releaseId: string - appInstanceName?: string -} export const releaseHistoryCurrentPageAtom = atom(0) export const deployReleaseMenuOpenReleaseIdAtom = atom(undefined) @@ -65,21 +58,6 @@ export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => { }) }) -export const deleteReleaseMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions(), -) - -export const updateReleaseMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.releaseService.updateRelease.mutationOptions(), -) - -export const exportReleaseDslMutationAtom = atomWithMutation(() => - mutationOptions({ - mutationKey: ['deployments', 'release-dsl-export'], - mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input), - }), -) - export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => { set(releaseHistoryCurrentPageAtom, Math.max(page, 0)) }) diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index fd8bf674aa7..bd4aef7b5fe 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -5,7 +5,7 @@ import type { InfiniteData, QueryKey } from '@tanstack/react-query' import type { ReactNode } from 'react' import { keepPreviousData } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithInfiniteQuery } from 'jotai-tanstack-query' import { useHydrateAtoms } from 'jotai/utils' import { parseAsString, useQueryState } from 'nuqs' import { consoleQuery } from '@/service/client' @@ -88,16 +88,3 @@ export const deploymentsListShowEmptyStateAtom = atom((get) => { export const deploymentsListHasFilterAtom = atom((get) => { return Boolean(get(deploymentsListKeywordsAtom).trim() || get(deploymentsListEnvironmentIdAtom)) }) - -export const environmentsFilterQueryAtom = atomWithQuery(() => - consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({ - input: { - query: { - // The filter lists every deployable environment; environment count is - // capped well below the 100-per-page maximum. - pageNumber: 1, - resultsPerPage: 100, - }, - }, - }), -) diff --git a/web/features/deployments/list/ui/environment-filter.tsx b/web/features/deployments/list/ui/environment-filter.tsx index c8468eec57f..d99f90d39df 100644 --- a/web/features/deployments/list/ui/environment-filter.tsx +++ b/web/features/deployments/list/ui/environment-filter.tsx @@ -8,14 +8,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useAtomValue } from 'jotai' +import { useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - envFilterQueryState, - environmentsFilterQueryAtom, -} from '../state' +import { consoleQuery } from '@/service/client' +import { envFilterQueryState } from '../state' type EnvironmentFilterOption = { value: string | null @@ -33,7 +31,16 @@ export function EnvironmentFilter({ className }: { const { t } = useTranslation('deployments') const [open, setOpen] = useState(false) const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState) - const environmentsQuery = useAtomValue(environmentsFilterQueryAtom) + const environmentsQuery = useQuery(consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({ + input: { + query: { + // The filter lists every deployable environment; environment count is + // capped well below the 100-per-page maximum. + pageNumber: 1, + resultsPerPage: 100, + }, + }, + })) const environmentOptions: EnvironmentFilterOption[] = environmentsQuery.data?.environments ?.map(environment => ({ value: environment.id, From 5b453069d19c162d8565d61ed6f0fbf95d542d73 Mon Sep 17 00:00:00 2001 From: kunal <96532810+Kunal152000@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:31:40 +0530 Subject: [PATCH 5/9] refactor: for db.session feedback service.export feedbacks (#37763) Co-authored-by: kunalj1-arch Co-authored-by: Asuka Minato --- api/controllers/console/app/message.py | 4 +- api/controllers/console/datasets/metadata.py | 17 +-- .../service_api/dataset/metadata.py | 15 +-- api/services/feedback_service.py | 4 +- api/services/metadata_service.py | 65 +++++----- .../services/test_feedback_service.py | 33 ++++-- .../services/test_metadata_partial_update.py | 12 +- .../services/test_metadata_service.py | 112 ++++++++++++------ .../service_api/dataset/test_metadata.py | 6 +- .../services/test_metadata_bug_complete.py | 8 +- .../services/test_metadata_nullable_bug.py | 6 +- 11 files changed, 173 insertions(+), 109 deletions(-) diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 726bd94cd7e..195a41f2888 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -341,8 +341,8 @@ class MessageFeedbackExportApi(Resource): try: export_data = FeedbackService.export_feedbacks( - db.session(), - app_id=app_model.id, + app_model.id, + session=db.session(), from_source=args.from_source, rating=args.rating, has_comment=args.has_comment, diff --git a/api/controllers/console/datasets/metadata.py b/api/controllers/console/datasets/metadata.py index 7195fe066fd..ebb490cd9e8 100644 --- a/api/controllers/console/datasets/metadata.py +++ b/api/controllers/console/datasets/metadata.py @@ -17,6 +17,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from extensions.ext_database import db from fields.dataset_fields import ( DatasetMetadataBuiltInFieldsResponse, DatasetMetadataListResponse, @@ -65,7 +66,9 @@ class DatasetMetadataCreateApi(Resource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - metadata = MetadataService.create_metadata(dataset_id_str, metadata_args, current_user, current_tenant_id) + metadata = MetadataService.create_metadata( + db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id + ) return dump_response(DatasetMetadataResponse, metadata), 201 @setup_required @@ -81,7 +84,7 @@ class DatasetMetadataCreateApi(Resource): dataset = DatasetService.get_dataset(dataset_id_str) if dataset is None: raise NotFound("Dataset not found.") - metadata = MetadataService.get_dataset_metadatas(dataset) + metadata = MetadataService.get_dataset_metadatas(db.session(), dataset) return dump_response(DatasetMetadataListResponse, metadata), 200 @@ -108,7 +111,7 @@ class DatasetMetadataApi(Resource): DatasetService.check_dataset_permission(dataset, current_user) metadata = MetadataService.update_metadata_name( - dataset_id_str, metadata_id_str, name, current_user, current_tenant_id + db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id ) return dump_response(DatasetMetadataResponse, metadata), 200 @@ -127,7 +130,7 @@ class DatasetMetadataApi(Resource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - MetadataService.delete_metadata(dataset_id_str, metadata_id_str) + MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str) # Frontend callers only await success and invalidate metadata caches; no response body is consumed. return "", 204 @@ -166,9 +169,9 @@ class DatasetMetadataBuiltInFieldActionApi(Resource): match action: case "enable": - MetadataService.enable_built_in_field(dataset) + MetadataService.enable_built_in_field(db.session(), dataset) case "disable": - MetadataService.disable_built_in_field(dataset) + MetadataService.disable_built_in_field(db.session(), dataset) # Frontend callers only await success and invalidate metadata caches; no response body is consumed. return "", 204 @@ -195,7 +198,7 @@ class DocumentMetadataEditApi(Resource): metadata_args = MetadataOperationData.model_validate(console_ns.payload or {}) - MetadataService.update_documents_metadata(dataset, metadata_args, current_user) + MetadataService.update_documents_metadata(db.session(), dataset, metadata_args, current_user) # Frontend callers only await success and invalidate caches; no response body is consumed. return "", 204 diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py index 426d008c412..7363e6bdfd4 100644 --- a/api/controllers/service_api/dataset/metadata.py +++ b/api/controllers/service_api/dataset/metadata.py @@ -8,6 +8,7 @@ from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.common.schema import register_response_schema_models, register_schema_model, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check +from extensions.ext_database import db from fields.dataset_fields import ( DatasetMetadataActionResponse, DatasetMetadataBuiltInFieldsResponse, @@ -85,7 +86,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - metadata = MetadataService.create_metadata(dataset_id_str, metadata_args) + metadata = MetadataService.create_metadata(db.session(), dataset_id_str, metadata_args) return dump_response(DatasetMetadataResponse, metadata), 201 @service_api_ns.doc( @@ -118,7 +119,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource): dataset = DatasetService.get_dataset(dataset_id_str) if dataset is None: raise NotFound("Dataset not found.") - metadata = MetadataService.get_dataset_metadatas(dataset) + metadata = MetadataService.get_dataset_metadatas(db.session(), dataset) return dump_response(DatasetMetadataListResponse, metadata), 200 @@ -158,7 +159,7 @@ class DatasetMetadataServiceApi(DatasetApiResource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, payload.name) + metadata = MetadataService.update_metadata_name(db.session(), dataset_id_str, metadata_id_str, payload.name) return dump_response(DatasetMetadataResponse, metadata), 200 @service_api_ns.doc( @@ -193,7 +194,7 @@ class DatasetMetadataServiceApi(DatasetApiResource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - MetadataService.delete_metadata(dataset_id_str, metadata_id_str) + MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str) return "", 204 @@ -263,9 +264,9 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource): match action: case "enable": - MetadataService.enable_built_in_field(dataset) + MetadataService.enable_built_in_field(db.session(), dataset) case "disable": - MetadataService.disable_built_in_field(dataset) + MetadataService.disable_built_in_field(db.session(), dataset) return dump_response(DatasetMetadataActionResponse, {"result": "success"}), 200 @@ -309,6 +310,6 @@ class DocumentMetadataEditServiceApi(DatasetApiResource): metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {}) - MetadataService.update_documents_metadata(dataset, metadata_args) + MetadataService.update_documents_metadata(db.session(), dataset, metadata_args) return dump_response(DatasetMetadataActionResponse, {"result": "success"}), 200 diff --git a/api/services/feedback_service.py b/api/services/feedback_service.py index 24cfb8aa852..62885c901b7 100644 --- a/api/services/feedback_service.py +++ b/api/services/feedback_service.py @@ -14,8 +14,9 @@ from models.model import Account, App, Conversation, Message, MessageFeedback class FeedbackService: @staticmethod def export_feedbacks( - session: Session, app_id: str, + *, + session: Session, from_source: str | None = None, rating: str | None = None, has_comment: bool | None = None, @@ -28,6 +29,7 @@ class FeedbackService: Args: app_id: Application ID + session: Database session used to run the export query from_source: Filter by feedback source ('user' or 'admin') rating: Filter by rating ('like' or 'dislike') has_comment: Only include feedback with comments diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index f9dcfd25c7f..d9cd65b2b39 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -2,9 +2,9 @@ import copy import logging from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session from core.rag.index_processor.constant.built_in_field import BuiltInField, MetadataDataSource -from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.datetime_utils import naive_utc_now from libs.login import resolve_account_fallback @@ -23,6 +23,7 @@ logger = logging.getLogger(__name__) class MetadataService: @staticmethod def create_metadata( + session: Session, dataset_id: str, metadata_args: MetadataArgs, current_user: Account | None = None, # TODO: the service_api is not migrated yet @@ -33,7 +34,7 @@ class MetadataService: raise ValueError("Metadata name cannot exceed 255 characters.") current_user, current_tenant_id = resolve_account_fallback(current_user, current_tenant_id) # check if metadata name already exists - if db.session.scalar( + if session.scalar( select(DatasetMetadata) .where( DatasetMetadata.tenant_id == current_tenant_id, @@ -53,12 +54,13 @@ class MetadataService: name=metadata_args.name, created_by=current_user.id, ) - db.session.add(metadata) - db.session.commit() + session.add(metadata) + session.commit() return metadata @staticmethod def update_metadata_name( + session: Session, dataset_id: str, metadata_id: str, name: str, @@ -72,7 +74,7 @@ class MetadataService: lock_key = f"dataset_metadata_lock_{dataset_id}" # check if metadata name already exists current_user, current_tenant_id = resolve_account_fallback(current_user, current_tenant_id) - if db.session.scalar( + if session.scalar( select(DatasetMetadata) .where( DatasetMetadata.tenant_id == current_tenant_id, @@ -87,7 +89,7 @@ class MetadataService: raise ValueError("Metadata name already exists in Built-in fields.") try: MetadataService.knowledge_base_metadata_lock_check(dataset_id, None) - metadata = db.session.scalar( + metadata = session.scalar( select(DatasetMetadata) .where(DatasetMetadata.id == metadata_id, DatasetMetadata.dataset_id == dataset_id) .limit(1) @@ -100,7 +102,7 @@ class MetadataService: metadata.updated_at = naive_utc_now() # update related documents - dataset_metadata_bindings = db.session.scalars( + dataset_metadata_bindings = session.scalars( select(DatasetMetadataBinding).where(DatasetMetadataBinding.metadata_id == metadata_id) ).all() if dataset_metadata_bindings: @@ -114,8 +116,8 @@ class MetadataService: value = doc_metadata.pop(old_name, None) doc_metadata[name] = value document.doc_metadata = doc_metadata - db.session.add(document) - db.session.commit() + session.add(document) + session.commit() return metadata except Exception: logger.exception("Update metadata name failed") @@ -124,21 +126,21 @@ class MetadataService: redis_client.delete(lock_key) @staticmethod - def delete_metadata(dataset_id: str, metadata_id: str): + def delete_metadata(session: Session, dataset_id: str, metadata_id: str): lock_key = f"dataset_metadata_lock_{dataset_id}" try: MetadataService.knowledge_base_metadata_lock_check(dataset_id, None) - metadata = db.session.scalar( + metadata = session.scalar( select(DatasetMetadata) .where(DatasetMetadata.id == metadata_id, DatasetMetadata.dataset_id == dataset_id) .limit(1) ) if metadata is None: raise ValueError("Metadata not found.") - db.session.delete(metadata) + session.delete(metadata) # deal related documents - dataset_metadata_bindings = db.session.scalars( + dataset_metadata_bindings = session.scalars( select(DatasetMetadataBinding).where(DatasetMetadataBinding.metadata_id == metadata_id) ).all() if dataset_metadata_bindings: @@ -151,8 +153,8 @@ class MetadataService: doc_metadata = copy.deepcopy(document.doc_metadata) doc_metadata.pop(metadata.name, None) document.doc_metadata = doc_metadata - db.session.add(document) - db.session.commit() + session.add(document) + session.commit() return metadata except Exception: logger.exception("Delete metadata failed") @@ -170,13 +172,13 @@ class MetadataService: ] @staticmethod - def enable_built_in_field(dataset: Dataset): + def enable_built_in_field(session: Session, dataset: Dataset): if dataset.built_in_field_enabled: return lock_key = f"dataset_metadata_lock_{dataset.id}" try: MetadataService.knowledge_base_metadata_lock_check(dataset.id, None) - db.session.add(dataset) + session.add(dataset) documents = DocumentService.get_working_documents_by_dataset_id(dataset.id) if documents: for document in documents: @@ -190,22 +192,22 @@ class MetadataService: doc_metadata[BuiltInField.last_update_date] = document.last_update_date.timestamp() doc_metadata[BuiltInField.source] = MetadataDataSource[document.data_source_type] document.doc_metadata = doc_metadata - db.session.add(document) + session.add(document) dataset.built_in_field_enabled = True - db.session.commit() + session.commit() except Exception: logger.exception("Enable built-in field failed") finally: redis_client.delete(lock_key) @staticmethod - def disable_built_in_field(dataset: Dataset): + def disable_built_in_field(session: Session, dataset: Dataset): if not dataset.built_in_field_enabled: return lock_key = f"dataset_metadata_lock_{dataset.id}" try: MetadataService.knowledge_base_metadata_lock_check(dataset.id, None) - db.session.add(dataset) + session.add(dataset) documents = DocumentService.get_working_documents_by_dataset_id(dataset.id) document_ids = [] if documents: @@ -220,10 +222,10 @@ class MetadataService: doc_metadata.pop(BuiltInField.last_update_date, None) doc_metadata.pop(BuiltInField.source, None) document.doc_metadata = doc_metadata - db.session.add(document) + session.add(document) document_ids.append(document.id) dataset.built_in_field_enabled = False - db.session.commit() + session.commit() except Exception: logger.exception("Disable built-in field failed") finally: @@ -231,6 +233,7 @@ class MetadataService: @staticmethod def update_documents_metadata( + session: Session, dataset: Dataset, metadata_args: MetadataOperationData, current_user: Account | None = None, # TODO: the service_api is not migrated yet @@ -259,11 +262,11 @@ class MetadataService: doc_metadata[BuiltInField.last_update_date] = document.last_update_date.timestamp() doc_metadata[BuiltInField.source] = MetadataDataSource[document.data_source_type] document.doc_metadata = doc_metadata - db.session.add(document) + session.add(document) # deal metadata binding (in the same transaction as the doc_metadata update) if not operation.partial_update: - db.session.execute( + session.execute( delete(DatasetMetadataBinding).where( DatasetMetadataBinding.document_id == operation.document_id ) @@ -272,7 +275,7 @@ class MetadataService: for metadata_value in operation.metadata_list: # check if binding already exists if operation.partial_update: - existing_binding = db.session.scalar( + existing_binding = session.scalar( select(DatasetMetadataBinding) .where( DatasetMetadataBinding.document_id == operation.document_id, @@ -290,10 +293,10 @@ class MetadataService: metadata_id=metadata_value.id, created_by=current_user.id, ) - db.session.add(dataset_metadata_binding) - db.session.commit() + session.add(dataset_metadata_binding) + session.commit() except Exception: - db.session.rollback() + session.rollback() logger.exception("Update documents metadata failed") raise finally: @@ -313,14 +316,14 @@ class MetadataService: redis_client.set(lock_key, 1, ex=3600) @staticmethod - def get_dataset_metadatas(dataset: Dataset): + def get_dataset_metadatas(session: Session, dataset: Dataset): return { "doc_metadata": [ { "id": item.get("id"), "name": item.get("name"), "type": item.get("type"), - "count": db.session.scalar( + "count": session.scalar( select(func.count(DatasetMetadataBinding.id)).where( DatasetMetadataBinding.metadata_id == item.get("id"), DatasetMetadataBinding.dataset_id == dataset.id, diff --git a/api/tests/test_containers_integration_tests/services/test_feedback_service.py b/api/tests/test_containers_integration_tests/services/test_feedback_service.py index e4fd81b53e7..c2b0385fc74 100644 --- a/api/tests/test_containers_integration_tests/services/test_feedback_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feedback_service.py @@ -97,8 +97,9 @@ class TestFeedbackService: ) # Test CSV export - result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="csv") - + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, session=mock_db_session, format_type="csv" + ) # Verify response structure assert hasattr(result, "headers") assert "text/csv" in result.headers["Content-Type"] @@ -128,7 +129,9 @@ class TestFeedbackService: ) # Test JSON export - result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="json") + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, session=mock_db_session, format_type="json" + ) # Verify response structure assert hasattr(result, "headers") @@ -158,8 +161,8 @@ class TestFeedbackService: # Test with filters result = FeedbackService.export_feedbacks( - mock_db_session, app_id=sample_data["app"].id, + session=mock_db_session, from_source=FeedbackFromSource.ADMIN, rating=FeedbackRating.DISLIKE, has_comment=True, @@ -175,7 +178,9 @@ class TestFeedbackService: """Test exporting feedback when no data exists.""" mock_db_session.execute.return_value = _execute_result([]) - result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="csv") + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, session=mock_db_session, format_type="csv" + ) # Should return an empty CSV with headers only assert hasattr(result, "headers") @@ -194,13 +199,13 @@ class TestFeedbackService: # Test with invalid start_date with pytest.raises(ValueError, match="Invalid start_date format"): FeedbackService.export_feedbacks( - mock_db_session, app_id=sample_data["app"].id, start_date="invalid-date-format" + app_id=sample_data["app"].id, session=mock_db_session, start_date="invalid-date-format" ) # Test with invalid end_date with pytest.raises(ValueError, match="Invalid end_date format"): FeedbackService.export_feedbacks( - mock_db_session, app_id=sample_data["app"].id, end_date="invalid-date-format" + app_id=sample_data["app"].id, session=mock_db_session, end_date="invalid-date-format" ) def test_export_feedbacks_invalid_format(self, mock_db_session, sample_data): @@ -208,8 +213,8 @@ class TestFeedbackService: with pytest.raises(ValueError, match="Unsupported format"): FeedbackService.export_feedbacks( - mock_db_session, app_id=sample_data["app"].id, + session=mock_db_session, format_type="xml", # Unsupported format ) @@ -239,7 +244,9 @@ class TestFeedbackService: ) # Test export - result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="json") + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, session=mock_db_session, format_type="json" + ) # Check JSON content json_content = json.loads(result.get_data(as_text=True)) @@ -290,7 +297,9 @@ class TestFeedbackService: ) # Test export - result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="csv") + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, session=mock_db_session, format_type="csv" + ) # Check that unicode content is preserved csv_content = result.get_data(as_text=True) @@ -320,7 +329,9 @@ class TestFeedbackService: ) # Test export - result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="json") + result = FeedbackService.export_feedbacks( + app_id=sample_data["app"].id, session=mock_db_session, format_type="json" + ) # Check JSON content for emoji ratings json_content = json.loads(result.get_data(as_text=True)) diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py b/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py index 5844441e6a5..fbdc265265d 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py @@ -95,7 +95,7 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - MetadataService.update_documents_metadata(dataset, metadata_args, current_account) + MetadataService.update_documents_metadata(db_session_with_containers, dataset, metadata_args, current_account) db_session_with_containers.expire_all() updated_doc = db_session_with_containers.get(Document, document.id) @@ -126,7 +126,7 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - MetadataService.update_documents_metadata(dataset, metadata_args, current_account) + MetadataService.update_documents_metadata(db_session_with_containers, dataset, metadata_args, current_account) db_session_with_containers.expire_all() updated_doc = db_session_with_containers.get(Document, document.id) @@ -168,7 +168,7 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - MetadataService.update_documents_metadata(dataset, metadata_args, current_account) + MetadataService.update_documents_metadata(db_session_with_containers, dataset, metadata_args, current_account) db_session_with_containers.expire_all() bindings = db_session_with_containers.scalars( @@ -202,6 +202,8 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - with patch("services.metadata_service.db.session.commit", side_effect=RuntimeError("database connection lost")): + with patch.object(db_session_with_containers, "commit", side_effect=RuntimeError("database connection lost")): with pytest.raises(RuntimeError, match="database connection lost"): - MetadataService.update_documents_metadata(dataset, metadata_args, current_account) + MetadataService.update_documents_metadata( + db_session_with_containers, dataset, metadata_args, current_account + ) diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_service.py b/api/tests/test_containers_integration_tests/services/test_metadata_service.py index 0c9e3830430..7cc9fc7e696 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_service.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_service.py @@ -183,7 +183,9 @@ class TestMetadataService: metadata_args = MetadataArgs(type="string", name="test_metadata") # Act: Execute the method under test - result = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + result = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Assert: Verify the expected outcomes assert result is not None @@ -218,7 +220,7 @@ class TestMetadataService: # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name cannot exceed 255 characters."): - MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + MetadataService.create_metadata(db_session_with_containers, dataset.id, metadata_args, account, tenant.id) def test_create_metadata_name_already_exists( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -236,14 +238,16 @@ class TestMetadataService: # Create first metadata first_metadata_args = MetadataArgs(type="string", name="duplicate_name") - MetadataService.create_metadata(dataset.id, first_metadata_args, account, tenant.id) + MetadataService.create_metadata(db_session_with_containers, dataset.id, first_metadata_args, account, tenant.id) # Try to create second metadata with same name second_metadata_args = MetadataArgs(type="number", name="duplicate_name") # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name already exists."): - MetadataService.create_metadata(dataset.id, second_metadata_args, account, tenant.id) + MetadataService.create_metadata( + db_session_with_containers, dataset.id, second_metadata_args, account, tenant.id + ) def test_create_metadata_name_conflicts_with_built_in_field( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -265,7 +269,7 @@ class TestMetadataService: # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name already exists in Built-in fields."): - MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + MetadataService.create_metadata(db_session_with_containers, dataset.id, metadata_args, account, tenant.id) def test_update_metadata_name_success( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -283,11 +287,15 @@ class TestMetadataService: # Create metadata first metadata_args = MetadataArgs(type="string", name="old_name") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Act: Execute the method under test new_name = "new_name" - result = MetadataService.update_metadata_name(dataset.id, metadata.id, new_name, account, tenant.id) + result = MetadataService.update_metadata_name( + db_session_with_containers, dataset.id, metadata.id, new_name, account, tenant.id + ) # Assert: Verify the expected outcomes assert result is not None @@ -316,14 +324,18 @@ class TestMetadataService: # Create metadata first metadata_args = MetadataArgs(type="string", name="old_name") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Try to update with too long name long_name = "a" * 256 # 256 characters, exceeding 255 limit # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name cannot exceed 255 characters."): - MetadataService.update_metadata_name(dataset.id, metadata.id, long_name, account, tenant.id) + MetadataService.update_metadata_name( + db_session_with_containers, dataset.id, metadata.id, long_name, account, tenant.id + ) def test_update_metadata_name_already_exists( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -341,14 +353,20 @@ class TestMetadataService: # Create two metadata entries first_metadata_args = MetadataArgs(type="string", name="first_metadata") - first_metadata = MetadataService.create_metadata(dataset.id, first_metadata_args, account, tenant.id) + first_metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, first_metadata_args, account, tenant.id + ) second_metadata_args = MetadataArgs(type="number", name="second_metadata") - second_metadata = MetadataService.create_metadata(dataset.id, second_metadata_args, account, tenant.id) + second_metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, second_metadata_args, account, tenant.id + ) # Try to update first metadata with second metadata's name with pytest.raises(ValueError, match="Metadata name already exists."): - MetadataService.update_metadata_name(dataset.id, first_metadata.id, "second_metadata", account, tenant.id) + MetadataService.update_metadata_name( + db_session_with_containers, dataset.id, first_metadata.id, "second_metadata", account, tenant.id + ) def test_update_metadata_name_conflicts_with_built_in_field( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -366,13 +384,17 @@ class TestMetadataService: # Create metadata first metadata_args = MetadataArgs(type="string", name="old_name") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Try to update with built-in field name built_in_field_name = BuiltInField.document_name with pytest.raises(ValueError, match="Metadata name already exists in Built-in fields."): - MetadataService.update_metadata_name(dataset.id, metadata.id, built_in_field_name, account, tenant.id) + MetadataService.update_metadata_name( + db_session_with_containers, dataset.id, metadata.id, built_in_field_name, account, tenant.id + ) def test_update_metadata_name_not_found( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -395,7 +417,9 @@ class TestMetadataService: new_name = "new_name" # Act: Execute the method under test - result = MetadataService.update_metadata_name(dataset.id, fake_metadata_id, new_name, account, tenant.id) + result = MetadataService.update_metadata_name( + db_session_with_containers, dataset.id, fake_metadata_id, new_name, account, tenant.id + ) # Assert: Verify the method returns None when metadata is not found assert result is None @@ -416,10 +440,12 @@ class TestMetadataService: # Create metadata first metadata_args = MetadataArgs(type="string", name="to_be_deleted") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Act: Execute the method under test - result = MetadataService.delete_metadata(dataset.id, metadata.id) + result = MetadataService.delete_metadata(db_session_with_containers, dataset.id, metadata.id) # Assert: Verify the expected outcomes assert result is not None @@ -450,7 +476,7 @@ class TestMetadataService: fake_metadata_id = str(uuid.uuid4()) # Use valid UUID format # Act: Execute the method under test - result = MetadataService.delete_metadata(dataset.id, fake_metadata_id) + result = MetadataService.delete_metadata(db_session_with_containers, dataset.id, fake_metadata_id) # Assert: Verify the method returns None when metadata is not found assert result is None @@ -474,7 +500,9 @@ class TestMetadataService: # Create metadata metadata_args = MetadataArgs(type="string", name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Create metadata binding binding = DatasetMetadataBinding( @@ -494,7 +522,7 @@ class TestMetadataService: db_session_with_containers.commit() # Act: Execute the method under test - result = MetadataService.delete_metadata(dataset.id, metadata.id) + result = MetadataService.delete_metadata(db_session_with_containers, dataset.id, metadata.id) # Assert: Verify the expected outcomes assert result is not None @@ -559,7 +587,7 @@ class TestMetadataService: assert dataset.built_in_field_enabled is False # Act: Execute the method under test - MetadataService.enable_built_in_field(dataset) + MetadataService.enable_built_in_field(db_session_with_containers, dataset) # Assert: Verify the expected outcomes @@ -595,7 +623,7 @@ class TestMetadataService: ]() # Act: Execute the method under test - MetadataService.enable_built_in_field(dataset) + MetadataService.enable_built_in_field(db_session_with_containers, dataset) # Assert: Verify the method returns early without changes db_session_with_containers.refresh(dataset) @@ -621,7 +649,7 @@ class TestMetadataService: ]() # Act: Execute the method under test - MetadataService.enable_built_in_field(dataset) + MetadataService.enable_built_in_field(db_session_with_containers, dataset) # Assert: Verify the expected outcomes @@ -668,7 +696,7 @@ class TestMetadataService: ] # Act: Execute the method under test - MetadataService.disable_built_in_field(dataset) + MetadataService.disable_built_in_field(db_session_with_containers, dataset) # Assert: Verify the expected outcomes db_session_with_containers.refresh(dataset) @@ -700,7 +728,7 @@ class TestMetadataService: ]() # Act: Execute the method under test - MetadataService.disable_built_in_field(dataset) + MetadataService.disable_built_in_field(db_session_with_containers, dataset) # Assert: Verify the method returns early without changes @@ -733,7 +761,7 @@ class TestMetadataService: ]() # Act: Execute the method under test - MetadataService.disable_built_in_field(dataset) + MetadataService.disable_built_in_field(db_session_with_containers, dataset) # Assert: Verify the expected outcomes db_session_with_containers.refresh(dataset) @@ -758,7 +786,9 @@ class TestMetadataService: # Create metadata metadata_args = MetadataArgs(type="string", name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Mock DocumentService.get_document mock_external_service_dependencies["document_service"].get_document.return_value = document @@ -777,7 +807,7 @@ class TestMetadataService: operation_data = MetadataOperationData(operation_data=[operation]) # Act: Execute the method under test - MetadataService.update_documents_metadata(dataset, operation_data, account) + MetadataService.update_documents_metadata(db_session_with_containers, dataset, operation_data, account) # Assert: Verify the expected outcomes @@ -822,7 +852,9 @@ class TestMetadataService: # Create metadata metadata_args = MetadataArgs(type="string", name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Mock DocumentService.get_document mock_external_service_dependencies["document_service"].get_document.return_value = document @@ -841,7 +873,7 @@ class TestMetadataService: operation_data = MetadataOperationData(operation_data=[operation]) # Act: Execute the method under test - MetadataService.update_documents_metadata(dataset, operation_data, account) + MetadataService.update_documents_metadata(db_session_with_containers, dataset, operation_data, account) # Assert: Verify the expected outcomes # Verify document metadata was updated with both custom and built-in fields @@ -869,7 +901,9 @@ class TestMetadataService: # Create metadata metadata_args = MetadataArgs(type="string", name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Create metadata operation data from services.entities.knowledge_entities.knowledge_entities import ( @@ -890,7 +924,7 @@ class TestMetadataService: # Act & Assert: The method should raise ValueError("Document not found.") # because the exception is now re-raised after rollback with pytest.raises(ValueError, match="Document not found"): - MetadataService.update_documents_metadata(dataset, operation_data, account) + MetadataService.update_documents_metadata(db_session_with_containers, dataset, operation_data, account) def test_knowledge_base_metadata_lock_check_dataset_id( self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps @@ -986,7 +1020,9 @@ class TestMetadataService: # Create metadata metadata_args = MetadataArgs(type="string", name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Create document and metadata binding document = self._create_test_document( @@ -1005,7 +1041,7 @@ class TestMetadataService: db_session_with_containers.commit() # Act: Execute the method under test - result = MetadataService.get_dataset_metadatas(dataset) + result = MetadataService.get_dataset_metadatas(db_session_with_containers, dataset) # Assert: Verify the expected outcomes assert result is not None @@ -1045,10 +1081,12 @@ class TestMetadataService: # Create metadata metadata_args = MetadataArgs(type="string", name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) + metadata = MetadataService.create_metadata( + db_session_with_containers, dataset.id, metadata_args, account, tenant.id + ) # Act: Execute the method under test - result = MetadataService.get_dataset_metadatas(dataset) + result = MetadataService.get_dataset_metadatas(db_session_with_containers, dataset) # Assert: Verify the expected outcomes assert result is not None @@ -1077,7 +1115,7 @@ class TestMetadataService: ) # Act: Execute the method under test - result = MetadataService.get_dataset_metadatas(dataset) + result = MetadataService.get_dataset_metadatas(db_session_with_containers, dataset) # Assert: Verify the expected outcomes assert result is not None diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py index 7a9978e742a..b77c783ae16 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py @@ -17,7 +17,7 @@ Decorator strategy: import uuid from inspect import unwrap -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest from flask import Flask @@ -408,7 +408,7 @@ class TestDatasetMetadataBuiltInFieldAction: assert status == 200 assert response["result"] == "success" - mock_meta_svc.enable_built_in_field.assert_called_once_with(mock_dataset) + mock_meta_svc.enable_built_in_field.assert_called_once_with(ANY, mock_dataset) @patch("controllers.service_api.dataset.metadata.MetadataService") @patch("controllers.service_api.dataset.metadata.DatasetService") @@ -439,7 +439,7 @@ class TestDatasetMetadataBuiltInFieldAction: ) assert status == 200 - mock_meta_svc.disable_built_in_field.assert_called_once_with(mock_dataset) + mock_meta_svc.disable_built_in_field.assert_called_once_with(ANY, mock_dataset) @patch("controllers.service_api.dataset.metadata.DatasetService") def test_action_dataset_not_found( diff --git a/api/tests/unit_tests/services/test_metadata_bug_complete.py b/api/tests/unit_tests/services/test_metadata_bug_complete.py index 36ea1fac1a4..6792243e9d0 100644 --- a/api/tests/unit_tests/services/test_metadata_bug_complete.py +++ b/api/tests/unit_tests/services/test_metadata_bug_complete.py @@ -48,13 +48,15 @@ class TestMetadataBugCompleteValidation: account = _make_account() # Should crash with TypeError with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123") + MetadataService.create_metadata(Mock(), "dataset-123", mock_metadata_args, account, "tenant-123") # Test update method as well account = _make_account() none_name = cast(str, None) with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.update_metadata_name("dataset-123", "metadata-456", none_name, account, "tenant-123") + MetadataService.update_metadata_name( + Mock(), "dataset-123", "metadata-456", none_name, account, "tenant-123" + ) def test_3_database_constraints_verification(self) -> None: """Test Layer 3: Verify database model has nullable=False constraints.""" @@ -97,7 +99,7 @@ class TestMetadataBugCompleteValidation: account = _make_account() with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123") + MetadataService.create_metadata(Mock(), "dataset-123", mock_metadata_args, account, "tenant-123") def test_7_end_to_end_validation_layers(self) -> None: """Test all validation layers work together correctly.""" diff --git a/api/tests/unit_tests/services/test_metadata_nullable_bug.py b/api/tests/unit_tests/services/test_metadata_nullable_bug.py index 27570a86f1a..ae93fe5ef51 100644 --- a/api/tests/unit_tests/services/test_metadata_nullable_bug.py +++ b/api/tests/unit_tests/services/test_metadata_nullable_bug.py @@ -37,7 +37,7 @@ class TestMetadataNullableBug: account = _make_account() # This should crash with TypeError when calling len(None) with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123") + MetadataService.create_metadata(Mock(), "dataset-123", mock_metadata_args, account, "tenant-123") def test_metadata_service_update_with_none_name_crashes(self) -> None: """Test that MetadataService.update_metadata_name crashes when name is None.""" @@ -45,7 +45,9 @@ class TestMetadataNullableBug: none_name = cast(str, None) # This should crash with TypeError when calling len(None) with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.update_metadata_name("dataset-123", "metadata-456", none_name, account, "tenant-123") + MetadataService.update_metadata_name( + Mock(), "dataset-123", "metadata-456", none_name, account, "tenant-123" + ) def test_api_layer_now_uses_pydantic_validation(self) -> None: """Verify that API layer relies on Pydantic validation instead of reqparse.""" From 50b3228bc7b26c9f501008916f60a6fa2dfb6e3e Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 23 Jun 2026 22:22:09 +0800 Subject: [PATCH 6/9] refactor(web): reuse infinite scroll hook in deployments (#37825) --- .../generated/enterprise/orpc.gen.ts | 9 - .../generated/enterprise/types.gen.ts | 47 +++-- .../contracts/generated/enterprise/zod.gen.ts | 93 +++----- .../__tests__/access-control.spec.tsx | 6 +- .../app/app-access-control/index.tsx | 6 +- .../ui/__tests__/source-step.spec.tsx | 76 ++++++- .../create-guide/ui/source-step.tsx | 24 +-- .../ui/__tests__/source-app-picker.spec.tsx | 96 ++++++++- .../create-release/ui/source-app-picker.tsx | 29 ++- .../access/__tests__/access-policy.spec.ts | 34 +-- .../access/__tests__/permissions.spec.tsx | 99 ++++++++- .../settings-tab/access/access-policy.ts | 22 +- .../access/permission-row-components.tsx | 4 +- .../deployments/detail/versions-tab/state.ts | 3 +- web/features/deployments/list/state/index.ts | 26 +-- web/features/deployments/list/ui/shell.tsx | 38 +--- web/features/deployments/nav/state.ts | 10 +- .../__tests__/use-infinite-scroll.spec.ts | 199 ++++++++++++++++++ .../shared/hooks/use-infinite-scroll.ts | 147 +++++++++++++ 19 files changed, 738 insertions(+), 230 deletions(-) create mode 100644 web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts create mode 100644 web/features/deployments/shared/hooks/use-infinite-scroll.ts diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 61503a7f742..764cea4fdc1 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -380,12 +380,8 @@ export const listRollbackTargets = oc ) .output(zDeploymentServiceListRollbackTargetsResponse) -/** - * CancelDeployment cancels the in-flight deployment on the environment. - */ export const cancelDeployment = oc .route({ - description: 'CancelDeployment cancels the in-flight deployment on the environment.', inputStructure: 'detailed', method: 'POST', operationId: 'DeploymentService_CancelDeployment', @@ -607,13 +603,8 @@ export const releaseService = { precheckRelease, } -/** - * ListEnvironments returns only the environments the current user can - * deploy to. - */ export const listEnvironments = oc .route({ - description: 'ListEnvironments returns only the environments the current user can\n deploy to.', inputStructure: 'detailed', method: 'GET', operationId: 'EnvironmentService_ListEnvironments', diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 600f8975678..882a85465b9 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -13,13 +13,13 @@ export const AccessMode = { export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode] -export const SubjectType = { - SUBJECT_TYPE_UNSPECIFIED: 'SUBJECT_TYPE_UNSPECIFIED', - SUBJECT_TYPE_ACCOUNT: 'SUBJECT_TYPE_ACCOUNT', - SUBJECT_TYPE_GROUP: 'SUBJECT_TYPE_GROUP', +export const AccessSubjectType = { + ACCESS_SUBJECT_TYPE_UNSPECIFIED: 'ACCESS_SUBJECT_TYPE_UNSPECIFIED', + ACCESS_SUBJECT_TYPE_ACCOUNT: 'ACCESS_SUBJECT_TYPE_ACCOUNT', + ACCESS_SUBJECT_TYPE_GROUP: 'ACCESS_SUBJECT_TYPE_GROUP', } as const -export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType] +export type AccessSubjectType = (typeof AccessSubjectType)[keyof typeof AccessSubjectType] export const AppRunnerLogStatus = { APP_RUNNER_LOG_STATUS_UNSPECIFIED: 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', @@ -295,7 +295,7 @@ export type AccessPolicy = { } export type AccessSubject = { - subjectType: SubjectType + subjectType: AccessSubjectType subjectId: string } @@ -598,7 +598,6 @@ export type Environment = { status: EnvironmentStatus statusMessage: string lastError?: Error - apiServer?: string namespace?: string managedBy?: string runtimeEndpoint?: string @@ -741,9 +740,6 @@ export type GetReleaseResponse = { export type K8sEnvironmentConfig = { namespace?: string - apiServer?: string - caBundle?: string - bearerToken?: string } export type ListApiKeysResponse = { @@ -832,6 +828,7 @@ export type PrecheckReleaseResponse = { canCreate: boolean matchedRelease?: ReleaseContentMatch unsupportedNodes: Array + unsupportedToolProviders: Array } export type PromoteRequest = { @@ -998,6 +995,14 @@ export type UnsupportedDslNode = { type: string } +export type UnsupportedToolProvider = { + nodeId: string + providerType: string + providerId?: string + providerName?: string + toolName?: string +} + export type UpdateAccessChannelsRequest = { appInstanceId?: string webAppEnabled?: boolean @@ -1362,7 +1367,6 @@ export type InfoConfigReply = { Branding?: BrandingInfo WebAppAuth?: WebAppAuthInfo PluginInstallationPermission?: PluginInstallationPermissionInfo - EnableAppDeploy?: boolean } export type InnerAdmission = { @@ -1458,6 +1462,19 @@ export type IsUserAllowedToAccessWebAppRes = { result?: boolean } +export type IssueMcpTokenReply = { + token?: string + expiresAt?: string + tokenType?: string +} + +export type IssueMcpTokenReq = { + userId?: string + tenantId?: string + appId?: string + audience?: string +} + export type JoinWorkspaceReply = { message?: string } @@ -1466,6 +1483,7 @@ export type JoinWorkspaceReq = { id?: string email?: string role?: string + rbacRole?: string } export type LicenseInfo = { @@ -1667,12 +1685,9 @@ export type PluginInstallationSettingsReply = { export type RbacRole = { id?: string - type?: string name?: string description?: string - isBuiltin?: boolean - category?: string - permissionKeys?: Array + permissions?: Array } export type ResetMemberPasswordReply = { @@ -1813,7 +1828,7 @@ export type SetDefaultWorkspaceReq = { export type Subject = { subjectId?: string - subjectType?: SubjectType + subjectType?: string accountData?: SubjectAccountData groupData?: SubjectGroupData } diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index d7a42b35d4c..85f74b22121 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -9,10 +9,10 @@ export const zAccessMode = z.enum([ 'ACCESS_MODE_PRIVATE_ALL', ]) -export const zSubjectType = z.enum([ - 'SUBJECT_TYPE_UNSPECIFIED', - 'SUBJECT_TYPE_ACCOUNT', - 'SUBJECT_TYPE_GROUP', +export const zAccessSubjectType = z.enum([ + 'ACCESS_SUBJECT_TYPE_UNSPECIFIED', + 'ACCESS_SUBJECT_TYPE_ACCOUNT', + 'ACCESS_SUBJECT_TYPE_GROUP', ]) export const zAppRunnerLogStatus = z.enum([ @@ -203,7 +203,7 @@ export const zLimitStatus = z.enum([ ]) export const zAccessSubject = z.object({ - subjectType: zSubjectType, + subjectType: zAccessSubjectType, subjectId: z.string(), }) @@ -254,10 +254,6 @@ export const zAppInstance = z.object({ updatedAt: z.iso.datetime(), }) -/** - * BootstrapAssignment is one runtime_instance assignment in a runner's startup - * baseline. - */ export const zBootstrapAssignment = z.object({ appId: z.string().optional(), environmentId: z.string().optional(), @@ -322,10 +318,6 @@ export const zCreateReleaseRequest = z.object({ sourceAppId: z.string().optional(), }) -/** - * CredentialCandidate is one tenant-visible credential a frontend may - * pick for a credential slot. It carries no secret. - */ export const zCredentialCandidate = z.object({ credentialId: z.string(), providerId: z.string(), @@ -334,20 +326,12 @@ export const zCredentialCandidate = z.object({ fromEnterprise: z.boolean(), }) -/** - * CredentialSelectionInput is one deploy-time plugin-credential - * selection: a shared credential id chosen for a required DSL slot. - */ export const zCredentialSelectionInput = z.object({ providerId: z.string(), category: zPluginCategory.optional(), credentialId: z.string(), }) -/** - * CredentialSlot is one model/tool plugin-credential requirement a - * Release's DSL declares, paired with the candidates selectable for it. - */ export const zCredentialSlot = z.object({ providerId: z.string(), category: zPluginCategory, @@ -406,10 +390,6 @@ export const zEnvironmentDeploymentRecord = z.object({ finalizedAt: z.iso.datetime().optional(), }) -/** - * Error is the package-wide failure shape, carried wherever an operation or - * resource reports an error. - */ export const zError = z.object({ code: z.string().optional(), message: z.string().optional(), @@ -445,7 +425,6 @@ export const zEnvironment = z.object({ status: zEnvironmentStatus, statusMessage: z.string(), lastError: zError.optional(), - apiServer: z.string().optional(), namespace: z.string().optional(), managedBy: z.string().optional(), runtimeEndpoint: z.string().optional(), @@ -523,9 +502,6 @@ export const zGetEnvironmentResponse = z.object({ export const zK8sEnvironmentConfig = z.object({ namespace: z.string().optional(), - apiServer: z.string().optional(), - caBundle: z.string().optional(), - bearerToken: z.string().optional(), }) export const zCreateEnvironmentRequest = z.object({ @@ -571,9 +547,6 @@ export const zDeployRequest = z.object({ expectedDslDigest: z.string().optional(), }) -/** - * Operator is who triggered the run (the "END USER OR ACCOUNT" column). - */ export const zOperator = z.object({ type: zOperatorType, id: z.string(), @@ -620,10 +593,6 @@ export const zPromoteRequest = z.object({ idempotencyKey: z.string(), }) -/** - * ReleaseContentMatch identifies an existing release whose DSL content is - * identical to the checked content. - */ export const zReleaseContentMatch = z.object({ releaseId: z.string(), displayName: z.string(), @@ -638,11 +607,6 @@ export const zReleaseEnvironmentAction = z.object({ currentReleaseId: z.string(), }) -/** - * ReleaseEnvironmentDeployment is an environment where the release is the - * active deployment, paired with that environment's runtime status so the - * version history can show running vs failed vs deploying. - */ export const zReleaseEnvironmentDeployment = z.object({ environment: zEnvironment, status: zRuntimeInstanceStatus, @@ -663,10 +627,6 @@ export const zReportRuntimeAssignmentStatusResponse = z.object({ stale: z.boolean().optional(), }) -/** - * RequiredSlot is an input requirement extracted from a Release's - * DSL. - */ export const zRequiredSlot = z.object({ type: zSlotType, providerId: z.string(), @@ -715,10 +675,6 @@ export const zDeployResponse = z.object({ deployment: zDeployment, }) -/** - * EnvironmentAppInstance is one app instance as seen from a single environment: - * its current release, runtime status, and derived last error in THIS env. - */ export const zEnvironmentAppInstance = z.object({ appInstance: zAppInstance.optional(), currentRelease: zRelease.optional(), @@ -759,10 +715,6 @@ export const zComputeReleaseDeploymentViewResponse = z.object({ options: zDeploymentOptions.optional(), }) -/** - * EnvironmentDeploymentHistoryItem is one deployment row in an environment's - * history, with a thin reference to the owning app instance. - */ export const zEnvironmentDeploymentHistoryItem = z.object({ deployment: zDeployment.optional(), appInstanceId: z.string().optional(), @@ -904,20 +856,25 @@ export const zUndeployResponse = z.object({ deployment: zDeployment, }) -/** - * UnsupportedDslNode identifies a workflow node whose type the app runner - * cannot execute. - */ export const zUnsupportedDslNode = z.object({ id: z.string(), type: z.string(), }) +export const zUnsupportedToolProvider = z.object({ + nodeId: z.string(), + providerType: z.string(), + providerId: z.string().optional(), + providerName: z.string().optional(), + toolName: z.string().optional(), +}) + export const zPrecheckReleaseResponse = z.object({ gateCommitId: z.string(), canCreate: z.boolean(), matchedRelease: zReleaseContentMatch.optional(), unsupportedNodes: z.array(zUnsupportedDslNode), + unsupportedToolProviders: z.array(zUnsupportedToolProvider), }) export const zUpdateAccessChannelsRequest = z.object({ @@ -1302,6 +1259,19 @@ export const zIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zIssueMcpTokenReply = z.object({ + token: z.string().optional(), + expiresAt: z.string().optional(), + tokenType: z.string().optional(), +}) + +export const zIssueMcpTokenReq = z.object({ + userId: z.string().optional(), + tenantId: z.string().optional(), + appId: z.string().optional(), + audience: z.string().optional(), +}) + export const zJoinWorkspaceReply = z.object({ message: z.string().optional(), }) @@ -1313,6 +1283,7 @@ export const zJoinWorkspaceReq = z.object({ id: z.string().optional(), email: z.string().optional(), role: z.string().optional(), + rbacRole: z.string().optional(), }) export const zLimitConfig = z.object({ @@ -1494,12 +1465,9 @@ export const zPluginInstallationSettingsReply = z.object({ export const zRbacRole = z.object({ id: z.string().optional(), - type: z.string().optional(), name: z.string().optional(), description: z.string().optional(), - isBuiltin: z.boolean().optional(), - category: z.string().optional(), - permissionKeys: z.array(z.string()).optional(), + permissions: z.array(z.string()).optional(), }) export const zGetMemberRbacRolesReply = z.object({ @@ -1778,7 +1746,7 @@ export const zGetWebAppWhitelistSubjectsRes = z.object({ */ export const zSubject = z.object({ subjectId: z.string().optional(), - subjectType: zSubjectType.optional(), + subjectType: z.string().optional(), accountData: zSubjectAccountData.optional(), groupData: zSubjectGroupData.optional(), }) @@ -2104,7 +2072,6 @@ export const zInfoConfigReply = z.object({ Branding: zBrandingInfo.optional(), WebAppAuth: zWebAppAuthInfo.optional(), PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(), - EnableAppDeploy: z.boolean().optional(), }) export const zWebOAuth2LoginReply = z.object({ diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index c8bce5401e6..7cfa29fe97a 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,6 +1,6 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' -import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' +import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -375,8 +375,8 @@ describe('AccessControl', () => { appId: app.id, accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, subjects: [ - { subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP }, - { subjectId: baseMember.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT }, + { subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP }, + { subjectId: baseMember.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }, ], }, }, diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 18c28d12757..13aa079092a 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen' import type { App } from '@/types/app' -import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' +import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' @@ -94,12 +94,12 @@ function AccessControlForm({ if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) { const subjects: Pick[] = [] specificGroups.forEach((group) => { - subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP }) + subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP }) }) specificMembers.forEach((member) => { subjects.push({ subjectId: member.id, - subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, }) }) submitData.subjects = subjects diff --git a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx index 3a99e1418a7..d0a1eaae638 100644 --- a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx +++ b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx @@ -1,7 +1,33 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SourceStepContent } from '../source-step' +const mocks = vi.hoisted(() => { + const sourceAppsQuery = { + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + } + + return { + sourceAppsQuery, + useInfiniteScroll: vi.fn(() => ({ + rootEl: null, + rootRef: vi.fn(), + sentinelEl: null, + sentinelRef: vi.fn(), + })), + } +}) + +vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({ + useInfiniteScroll: mocks.useInfiniteScroll, +})) + vi.mock('@/features/deployments/create-guide/state', async () => { const { atom } = await import('jotai') const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp') @@ -22,15 +48,7 @@ vi.mock('@/features/deployments/create-guide/state', async () => { }), selectSourceAppAtom: emptyActionAtom, setSourceSearchTextAtom: emptyActionAtom, - sourceAppsQueryAtom: atom({ - data: { pages: [{ data: [] }] }, - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - isLoading: false, - isPlaceholderData: false, - fetchNextPage: vi.fn(), - }), + sourceAppsQueryAtom: atom(mocks.sourceAppsQuery), sourceCanGoNextAtom: atom(false), sourceSearchTextAtom: atom(''), unsupportedDslNodesAtom: atom([]), @@ -38,6 +56,19 @@ vi.mock('@/features/deployments/create-guide/state', async () => { }) describe('SourceStepContent', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(mocks.sourceAppsQuery, { + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + }) + }) + it('should hide the import DSL option when deployment DSL import is disabled', () => { render() @@ -46,4 +77,29 @@ describe('SourceStepContent', () => { expect(screen.queryByText(/createGuide\.methods\.importDsl\.description/)).not.toBeInTheDocument() expect(screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ })).toBeInTheDocument() }) + + it('should use infinite scroll to load more source apps', () => { + Object.assign(mocks.sourceAppsQuery, { + data: { + pages: [{ + data: [{ + id: 'app-1', + name: 'Workflow App', + }], + }], + }, + hasNextPage: true, + }) + + render() + + expect(mocks.useInfiniteScroll).toHaveBeenCalledWith( + mocks.sourceAppsQuery, + expect.objectContaining({ + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }), + ) + expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument() + }) }) diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index d75b9965773..be2dcff89c1 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -32,6 +32,7 @@ import { unsupportedDslNodesAtom, } from '@/features/deployments/create-guide/state' import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' +import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll' import { StepShell } from './layout' const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app'] @@ -187,9 +188,14 @@ function SourceAppList() { const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom) const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[] const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0) + const { rootRef, sentinelRef } = useInfiniteScroll(sourceAppsQuery, { + enabled: !sourceAppsLoading, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }) return ( -
+
{sourceAppsLoading ? : sourceApps.length === 0 @@ -208,20 +214,12 @@ function SourceAppList() { onSelect={() => selectSourceApp(app)} /> ))} - {sourceAppsQuery.hasNextPage && ( -
- + {sourceAppsQuery.isFetchingNextPage && ( +
+ {t('createModal.loadingApps')}
)} + {sourceAppsQuery.hasNextPage && )}
diff --git a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx index dbf553b8d1f..d77bfde6f90 100644 --- a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx +++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx @@ -1,8 +1,51 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SourceAppPicker } from '../source-app-picker' +const mocks = vi.hoisted(() => { + const sourceAppsQuery = { + data: { + pages: [{ + data: [{ + id: 'app-1', + name: 'Workflow App', + }], + }], + }, + error: null, + fetchNextPage: vi.fn(), + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + } + + return { + sourceAppsQuery, + useInfiniteScroll: vi.fn(() => ({ + rootEl: null, + rootRef: vi.fn(), + sentinelEl: null, + sentinelRef: vi.fn(), + })), + } +}) + +vi.mock('@/features/deployments/create-release/state', async () => { + const { atom } = await import('jotai') + + return { + createReleaseSourceAppSearchTextAtom: atom(''), + createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery), + } +}) + +vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({ + useInfiniteScroll: mocks.useInfiniteScroll, +})) + function renderSourceAppPicker(disabled: boolean) { const queryClient = new QueryClient({ defaultOptions: { @@ -24,10 +67,59 @@ function renderSourceAppPicker(disabled: boolean) { } describe('SourceAppPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(mocks.sourceAppsQuery, { + data: { + pages: [{ + data: [{ + id: 'app-1', + name: 'Workflow App', + }], + }], + }, + error: null, + fetchNextPage: vi.fn(), + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + }) + }) + it('should disable the switch control when disabled', () => { renderSourceAppPicker(true) expect(screen.getByText('Workflow 1')).toBeInTheDocument() expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).toBeDisabled() }) + + it('should use infinite scroll to load more apps when the picker is open', async () => { + const user = userEvent.setup() + + renderSourceAppPicker(false) + + expect(mocks.useInfiniteScroll).toHaveBeenCalledWith( + mocks.sourceAppsQuery, + expect.objectContaining({ + enabled: false, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }), + ) + + await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })) + + await waitFor(() => { + expect(mocks.useInfiniteScroll).toHaveBeenLastCalledWith( + mocks.sourceAppsQuery, + expect.objectContaining({ + enabled: true, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }), + ) + }) + expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument() + }) }) diff --git a/web/features/deployments/create-release/ui/source-app-picker.tsx b/web/features/deployments/create-release/ui/source-app-picker.tsx index 633cea433fc..2f3c06fb0bd 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -1,7 +1,6 @@ 'use client' import type { SourceAppPickerValue } from '../state' import type { App } from '@/types/app' -import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Combobox, @@ -19,6 +18,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll' import { TitleTooltip } from '../../components/title-tooltip' import { createReleaseSourceAppSearchTextAtom, @@ -134,13 +134,18 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { const [isShow, setIsShow] = useState(false) const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom) const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom) + const sourceAppsQuery = useAtomValue(createReleaseSourceAppsQueryAtom) const { data, isLoading, isFetchingNextPage, - fetchNextPage, hasNextPage, - } = useAtomValue(createReleaseSourceAppsQueryAtom) + } = sourceAppsQuery + const { rootRef, sentinelRef } = useInfiniteScroll(sourceAppsQuery, { + enabled: isShow && !disabled, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }) const apps = data?.pages.flatMap(page => page.data) ?? [] @@ -202,7 +207,7 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { />
-
+
{(isLoading || isFetchingNextPage) && apps.length === 0 && } {(app: App) => ( @@ -214,20 +219,12 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { {t('createModal.appSearchEmpty')} )} - {hasNextPage && ( -
- + {isFetchingNextPage && apps.length > 0 && ( +
+ {t('createModal.loadingApps')}
)} + {hasNextPage &&
diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts b/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts index 4a6bce99f87..a2ec5ca6a50 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts +++ b/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts @@ -2,7 +2,7 @@ import type { AccessPolicy, Subject, } from '@dify/contracts/enterprise/types.gen' -import { AccessMode, SubjectType } from '@dify/contracts/enterprise/types.gen' +import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { describe, expect, it } from 'vitest' import { AccessMode as AppAccessMode } from '@/models/access-control' import { @@ -56,7 +56,7 @@ describe('access policy mode mapping', () => { describe('access policy subject conversion', () => { it('should normalize resolved group and account subjects', () => { expect(normalizeResolvedSubject({ - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, groupData: { id: 'group-1', name: 'Admins', @@ -64,53 +64,53 @@ describe('access policy subject conversion', () => { }, })).toEqual({ id: 'group-1', - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: 'Admins', memberCount: 3, }) expect(normalizeResolvedSubject({ - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, accountData: { id: 'account-1', email: 'member@example.com', }, })).toEqual({ id: 'account-1', - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, name: 'member@example.com', }) }) it('should ignore unsupported subjects and subjects without ids', () => { - expect(normalizeResolvedSubject({ subjectType: SubjectType.SUBJECT_TYPE_GROUP })).toBeUndefined() - expect(normalizeResolvedSubject({ subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT })).toBeUndefined() - expect(normalizeResolvedSubject({ subjectType: SubjectType.SUBJECT_TYPE_UNSPECIFIED } as Subject)).toBeUndefined() + expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP })).toBeUndefined() + expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT })).toBeUndefined() + expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_UNSPECIFIED } as Subject)).toBeUndefined() }) it('should preserve labels when reading selected subjects from policy', () => { expect(selectedSubjectsFromPolicy(policy({ subjects: [ - { subjectId: 'group-1', subjectType: SubjectType.SUBJECT_TYPE_GROUP }, - { subjectId: 'account-1', subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT }, + { subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP }, + { subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }, ], }), [ { id: 'group-1', - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: 'Admins', memberCount: 3, }, ])).toEqual([ { id: 'group-1', - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: 'Admins', memberCount: 3, }, { id: 'account-1', - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, }, ]) @@ -121,20 +121,20 @@ describe('access policy subject conversion', () => { const subjects = [ { id: 'group-1', - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: 'Admins', memberCount: 3, }, { id: 'account-1', - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, name: 'Member', }, ] expect(policySubjects(subjects)).toEqual([ - { subjectId: 'group-1', subjectType: SubjectType.SUBJECT_TYPE_GROUP }, - { subjectId: 'account-1', subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT }, + { subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP }, + { subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }, ]) const selection = accessControlSelectionFromSubjects(subjects) diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx index 891f117efea..9c21ac5083d 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx @@ -1,6 +1,6 @@ import type { AccessPolicy, Environment, EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.gen' import type { ReactNode } from 'react' -import { AccessMode, SubjectType } from '@dify/contracts/enterprise/types.gen' +import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { fireEvent, render, screen } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' import { describe, expect, it, vi } from 'vitest' @@ -10,10 +10,20 @@ import { AccessPermissionsSection } from '../permissions-section' const mockMutate = vi.hoisted(() => vi.fn()) vi.mock('@tanstack/react-query', () => ({ + useInfiniteQuery: () => ({ + data: { pages: [] }, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + isLoading: false, + }), useMutation: () => ({ isPending: false, mutate: mockMutate, }), + useQuery: () => ({ + data: undefined, + isPending: false, + }), })) vi.mock('@/service/client', () => ({ @@ -63,11 +73,11 @@ function createSpecificAccessPolicy(): AccessPolicy { subjects: [ { subjectId: 'group-1', - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, }, { subjectId: 'member-1', - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, }, ], } @@ -106,6 +116,89 @@ describe('EnvironmentPermissionRow', () => { expect(screen.getByText('deployments.access.permission.organization')).toBeInTheDocument() }) + it('should show the updated policy after success', () => { + mockMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + + renderWithAtomStore( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ })) + fireEvent.click(screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + expect(mockMutate).toHaveBeenCalledWith( + { + params: { + appInstanceId: 'app-instance-1', + environmentId: 'environment-1', + }, + body: { + appInstanceId: 'app-instance-1', + environmentId: 'environment-1', + mode: AccessMode.ACCESS_MODE_PUBLIC, + subjects: [], + }, + }, + expect.objectContaining({ + onError: expect.any(Function), + onSuccess: expect.any(Function), + }), + ) + expect(screen.getByText('deployments.access.permission.anyone')).toBeInTheDocument() + }) + + it('should submit specific subjects with the deployment access subject type', () => { + mockMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + + renderWithAtomStore( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + expect(mockMutate).toHaveBeenCalledWith( + { + params: { + appInstanceId: 'app-instance-1', + environmentId: 'environment-1', + }, + body: { + appInstanceId: 'app-instance-1', + environmentId: 'environment-1', + mode: AccessMode.ACCESS_MODE_PRIVATE, + subjects: [ + { + subjectId: 'group-1', + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, + }, + { + subjectId: 'member-1', + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, + }, + ], + }, + }, + expect.objectContaining({ + onError: expect.any(Function), + onSuccess: expect.any(Function), + }), + ) + }) + it('should show specific subject counts in the access summary', () => { renderWithAtomStore( = { export type SelectableAccessSubject = { id: string - subjectType: AccessSubjectType + subjectType: AccessSubjectTypeValue name?: string memberCount?: number } @@ -64,27 +64,27 @@ export function appAccessModeToPermissionKey(mode: AppAccessMode): AccessPermiss } export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubject | undefined { - if (subject.subjectType === SubjectType.SUBJECT_TYPE_GROUP) { + if (subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP) { const id = subject.subjectId || subject.groupData?.id if (!id) return undefined return { id, - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: subject.groupData?.name, memberCount: subject.groupData?.groupSize, } } - if (subject.subjectType === SubjectType.SUBJECT_TYPE_ACCOUNT) { + if (subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT) { const id = subject.subjectId || subject.accountData?.id if (!id) return undefined return { id, - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, name: subject.accountData?.name || subject.accountData?.email, } } @@ -144,10 +144,10 @@ function selectableSubjectToAccount(subject: SelectableAccessSubject): AccessCon export function accessControlSelectionFromSubjects(subjects: SelectableAccessSubject[]): AccessSubjectSelectionValue { return { groups: subjects - .filter(subject => subject.subjectType === SubjectType.SUBJECT_TYPE_GROUP) + .filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP) .map(selectableSubjectToGroup), members: subjects - .filter(subject => subject.subjectType === SubjectType.SUBJECT_TYPE_ACCOUNT) + .filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT) .map(selectableSubjectToAccount), } } @@ -156,13 +156,13 @@ export function subjectsFromAccessControlSelection(value: AccessSubjectSelection return [ ...value.groups.map((group): SelectableAccessSubject => ({ id: group.id, - subjectType: SubjectType.SUBJECT_TYPE_GROUP, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: group.name, memberCount: group.groupSize, })), ...value.members.map((member): SelectableAccessSubject => ({ id: member.id, - subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, name: member.name || member.email, })), ] diff --git a/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx b/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx index 25913c33543..1fc6c4adb7f 100644 --- a/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx +++ b/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx @@ -6,7 +6,7 @@ import type { } from './access-policy' import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types' import type { AccessControlDraft } from '@/app/components/app/app-access-control/store' -import { SubjectType } from '@dify/contracts/enterprise/types.gen' +import { AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import { AccessControlDialog } from '@/app/components/app/app-access-control/access-control-dialog' @@ -35,7 +35,7 @@ export function PermissionSummaryButton({ onClick: () => void }) { const { t } = useTranslation('deployments') - const groupCount = subjects?.filter(subject => subject.subjectType === SubjectType.SUBJECT_TYPE_GROUP).length ?? 0 + const groupCount = subjects?.filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP).length ?? 0 const memberCount = (subjects?.length ?? 0) - groupCount const countLabels = [ ...(groupCount > 0 ? [t('access.members.groupCount', { count: groupCount })] : []), diff --git a/web/features/deployments/detail/versions-tab/state.ts b/web/features/deployments/detail/versions-tab/state.ts index 5cf772bc54f..4d90228a3af 100644 --- a/web/features/deployments/detail/versions-tab/state.ts +++ b/web/features/deployments/detail/versions-tab/state.ts @@ -1,6 +1,5 @@ 'use client' -import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen' import { keepPreviousData, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithQuery } from 'jotai-tanstack-query' @@ -11,7 +10,7 @@ import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination' export const releaseHistoryCurrentPageAtom = atom(0) export const deployReleaseMenuOpenReleaseIdAtom = atom(undefined) -export const releaseHistoryQueryAtom = atomWithQuery((get) => { +export const releaseHistoryQueryAtom = atomWithQuery((get) => { const appInstanceId = get(deploymentRouteAppInstanceIdAtom) const currentPage = get(releaseHistoryCurrentPageAtom) diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index bd4aef7b5fe..2e24efb7ef6 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -1,7 +1,5 @@ 'use client' -import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen' -import type { InfiniteData, QueryKey } from '@tanstack/react-query' import type { ReactNode } from 'react' import { keepPreviousData } from '@tanstack/react-query' import { atom } from 'jotai' @@ -34,21 +32,7 @@ export function DeploymentsListStateBoundary({ children }: { return children } -function listDeploymentStatusPollingInterval(data?: InfiniteData) { - const rows = data?.pages?.flatMap(page => - page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments), - ) ?? [] - - return deploymentStatusPollingInterval(rows) -} - -export const deploymentsListQueryAtom = atomWithInfiniteQuery< - ListAppInstanceSummariesResponse, - Error, - InfiniteData, - QueryKey, - number ->((get) => { +export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => { const queryKeywords = get(deploymentsListKeywordsAtom).trim() const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined @@ -64,7 +48,13 @@ export const deploymentsListQueryAtom = atomWithInfiniteQuery< getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination), initialPageParam: 1, placeholderData: keepPreviousData, - refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data), + refetchInterval: (query) => { + const rows = query.state.data?.pages.flatMap(page => + page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments), + ) ?? [] + + return deploymentStatusPollingInterval(rows) + }, }) }) diff --git a/web/features/deployments/list/ui/shell.tsx b/web/features/deployments/list/ui/shell.tsx index d564fa0114e..2aea1d6b3db 100644 --- a/web/features/deployments/list/ui/shell.tsx +++ b/web/features/deployments/list/ui/shell.tsx @@ -6,11 +6,11 @@ import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' import { useAtomValue } from 'jotai' import { debounce, useQueryState } from 'nuqs' -import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { StudioListHeader } from '@/app/components/apps/studio-list-header' import { SkeletonRectangle } from '@/app/components/base/skeleton' import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state' +import { useInfiniteScroll } from '../../shared/hooks/use-infinite-scroll' import { deploymentsListHasFilterAtom, deploymentsListQueryAtom, @@ -157,48 +157,22 @@ function DeploymentsListControls() { export function DeploymentsListShell() { const { t } = useTranslation('deployments') - const containerRef = useRef(null) - const anchorRef = useRef(null) const deploymentsListQuery = useAtomValue(deploymentsListQueryAtom) const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom) const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom) const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom) const { - error, - fetchNextPage, - hasNextPage, isError, isFetchingNextPage, - isLoading, } = deploymentsListQuery - useEffect(() => { - if (!hasNextPage || isLoading || isFetchingNextPage || error) - return - - const anchor = anchorRef.current - const container = containerRef.current - if (!anchor || !container) - return - - const observer = new IntersectionObserver((entries) => { - if (entries[0]?.isIntersecting) - void fetchNextPage() - }, { - root: container, - rootMargin: '160px', - threshold: 0.1, - }) - - observer.observe(anchor) - return () => observer.disconnect() - }, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading]) + const { rootRef, sentinelRef } = useInfiniteScroll(deploymentsListQuery) return ( -
+
@@ -215,10 +189,8 @@ export function DeploymentsListShell() { /> ))} {isFetchingNextPage && } + - -
-
) } diff --git a/web/features/deployments/nav/state.ts b/web/features/deployments/nav/state.ts index 558c14a1a8c..b9242c38637 100644 --- a/web/features/deployments/nav/state.ts +++ b/web/features/deployments/nav/state.ts @@ -3,9 +3,7 @@ import type { AppInstance, GetAppInstanceResponse, - ListAppInstancesResponse, } from '@dify/contracts/enterprise/types.gen' -import type { InfiniteData, QueryKey } from '@tanstack/react-query' import type { NavItem } from '@/app/components/header/nav/nav-selector' import { keepPreviousData, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' @@ -64,13 +62,7 @@ const deploymentsNavCurrentInstanceQueryAtom = atomWithQuery((get) => { }) }) -export const deploymentsNavListQueryAtom = atomWithInfiniteQuery< - ListAppInstancesResponse, - Error, - InfiniteData, - QueryKey, - number ->((get) => { +export const deploymentsNavListQueryAtom = atomWithInfiniteQuery((get) => { const isActive = get(deploymentsRouteActiveAtom) return consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({ diff --git a/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts b/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts new file mode 100644 index 00000000000..83b0fa7a6d0 --- /dev/null +++ b/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts @@ -0,0 +1,199 @@ +import type { InfiniteScrollQueryResult, UseInfiniteScrollOptions } from '../use-infinite-scroll' +import { act, render } from '@testing-library/react' +import { createElement } from 'react' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { useInfiniteScroll } from '../use-infinite-scroll' + +let intersectionCallback: IntersectionObserverCallback | undefined +let intersectionOptions: IntersectionObserverInit | undefined +const observe = vi.fn() +const disconnect = vi.fn() +const unobserve = vi.fn() +const originalIntersectionObserver = globalThis.IntersectionObserver + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '' + readonly thresholds: ReadonlyArray + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + intersectionCallback = callback + intersectionOptions = options + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + } + + observe = observe + unobserve = unobserve + disconnect = disconnect + takeRecords = () => [] +} + +function TestInfiniteScroll({ + options, + query, +}: { + options?: UseInfiniteScrollOptions + query: InfiniteScrollQueryResult +}) { + const { rootRef, sentinelRef } = useInfiniteScroll(query, options) + + return createElement( + 'div', + { 'ref': rootRef, 'data-testid': 'scroll-root' }, + createElement('div', { 'ref': sentinelRef, 'data-testid': 'scroll-sentinel' }), + ) +} + +function createInfiniteScrollQuery(overrides: Partial = {}): InfiniteScrollQueryResult { + return { + error: null, + fetchNextPage: vi.fn(() => Promise.resolve()), + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + ...overrides, + } +} + +function renderInfiniteScroll( + query: InfiniteScrollQueryResult, + options?: UseInfiniteScrollOptions, +) { + const view = render(createElement(TestInfiniteScroll, { options, query })) + + return { + ...view, + root: view.getByTestId('scroll-root'), + sentinel: view.getByTestId('scroll-sentinel'), + } +} + +function triggerIntersection(isIntersecting: boolean) { + if (!intersectionCallback) + throw new Error('Expected IntersectionObserver callback to be registered') + + intersectionCallback([ + { isIntersecting } as IntersectionObserverEntry, + ], {} as IntersectionObserver) +} + +describe('useInfiniteScroll', () => { + beforeEach(() => { + vi.clearAllMocks() + intersectionCallback = undefined + intersectionOptions = undefined + globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver + }) + + afterAll(() => { + globalThis.IntersectionObserver = originalIntersectionObserver + }) + + // The hook owns both refs and wires the sentinel to the scroll container root. + it('should observe the sentinel with the scroll root', () => { + const query = createInfiniteScrollQuery() + const { root, sentinel } = renderInfiniteScroll(query) + + expect(observe).toHaveBeenCalledWith(sentinel) + expect(intersectionOptions?.root).toBe(root) + expect(intersectionOptions?.rootMargin).toBe('0px 0px 300px 0px') + expect(intersectionOptions?.threshold).toBe(0) + }) + + // Pagination starts when the sentinel enters the configured observer area. + it('should load more when the sentinel intersects', () => { + const query = createInfiniteScrollQuery() + renderInfiniteScroll(query) + + triggerIntersection(true) + + expect(query.fetchNextPage).toHaveBeenCalledTimes(1) + expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false }) + }) + + // Non-intersecting observer entries should not advance the query. + it('should not load more when the sentinel is outside the observer area', () => { + const query = createInfiniteScrollQuery() + renderInfiniteScroll(query) + + triggerIntersection(false) + + expect(query.fetchNextPage).not.toHaveBeenCalled() + }) + + // Query state should gate observer registration so stale or duplicate loads do not fire. + it.each([ + ['has no next page', { hasNextPage: false }], + ['is loading', { isLoading: true }], + ['is fetching the next page', { isFetchingNextPage: true }], + ['is fetching and guarded', { isFetching: true }], + ['has an error', { error: new Error('load failed') }], + ] as const)('should not observe when the query %s', (_label, overrides) => { + const query = createInfiniteScrollQuery(overrides) + + renderInfiniteScroll(query) + + expect(observe).not.toHaveBeenCalled() + expect(query.fetchNextPage).not.toHaveBeenCalled() + }) + + // Options are passed through to IntersectionObserver and fetchNextPage. + it('should use custom observer and fetch options', () => { + const query = createInfiniteScrollQuery() + renderInfiniteScroll(query, { + cancelRefetch: true, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }) + + triggerIntersection(true) + + expect(intersectionOptions?.rootMargin).toBe('0px 0px 160px 0px') + expect(intersectionOptions?.threshold).toBe(0.1) + expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: true }) + }) + + // Window mode observes against the viewport instead of the local scroll container. + it('should use the viewport root when useWindow is enabled', () => { + const query = createInfiniteScrollQuery() + renderInfiniteScroll(query, { useWindow: true }) + + expect(intersectionOptions?.root).toBeNull() + }) + + // The local lock avoids repeated calls while TanStack Query is still resolving fetchNextPage. + it('should not request another page while the previous request is pending', async () => { + let resolveFetch: (value?: unknown) => void = () => undefined + const fetchNextPage = vi.fn(() => new Promise((resolve) => { + resolveFetch = resolve + })) + const query = createInfiniteScrollQuery({ fetchNextPage }) + renderInfiniteScroll(query) + + triggerIntersection(true) + triggerIntersection(true) + + expect(fetchNextPage).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFetch() + await Promise.resolve() + }) + }) + + // Cleanup matters when the list unmounts or query state recreates the observer. + it('should disconnect the observer on unmount', () => { + const query = createInfiniteScrollQuery() + const { unmount } = renderInfiniteScroll(query) + + unmount() + + expect(disconnect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/features/deployments/shared/hooks/use-infinite-scroll.ts b/web/features/deployments/shared/hooks/use-infinite-scroll.ts new file mode 100644 index 00000000000..ba865c1dd7e --- /dev/null +++ b/web/features/deployments/shared/hooks/use-infinite-scroll.ts @@ -0,0 +1,147 @@ +import type { RefCallback } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + +type FetchNextPageOptions = { + cancelRefetch?: boolean +} + +export type InfiniteScrollQueryResult = { + error?: unknown + fetchNextPage: (options?: FetchNextPageOptions) => Promise | unknown + hasNextPage?: boolean + isFetching?: boolean + isFetchingNextPage: boolean + isLoading?: boolean +} + +export type UseInfiniteScrollOptions = { + cancelRefetch?: boolean + enabled?: boolean + guardOnFetching?: boolean + rootMargin?: string + threshold?: number | number[] + useWindow?: boolean +} + +type UseInfiniteScrollResult = { + rootEl: TRoot | null + rootRef: RefCallback + sentinelEl: TTarget | null + sentinelRef: RefCallback +} + +export function useInfiniteScroll< + TRoot extends Element = HTMLDivElement, + TTarget extends Element = HTMLDivElement, +>( + query: InfiniteScrollQueryResult, + options: UseInfiniteScrollOptions = {}, +): UseInfiniteScrollResult { + const { + cancelRefetch = false, + enabled = true, + guardOnFetching = true, + rootMargin = '0px 0px 300px 0px', + threshold = 0, + useWindow = false, + } = options + + const [rootEl, setRootEl] = useState(null) + const [sentinelEl, setSentinelEl] = useState(null) + const loadingLockRef = useRef(false) + + const latestRef = useRef({ + cancelRefetch, + enabled, + error: query.error, + fetchNextPage: query.fetchNextPage, + guardOnFetching, + hasNextPage: Boolean(query.hasNextPage), + isFetching: query.isFetching ?? false, + isFetchingNextPage: query.isFetchingNextPage, + isLoading: query.isLoading ?? false, + }) + + latestRef.current = { + cancelRefetch, + enabled, + error: query.error, + fetchNextPage: query.fetchNextPage, + guardOnFetching, + hasNextPage: Boolean(query.hasNextPage), + isFetching: query.isFetching ?? false, + isFetchingNextPage: query.isFetchingNextPage, + isLoading: query.isLoading ?? false, + } + + const rootRef = useCallback((node: TRoot | null) => { + setRootEl(node) + }, []) + + const sentinelRef = useCallback((node: TTarget | null) => { + setSentinelEl(node) + }, []) + + const canLoad = enabled + && Boolean(query.hasNextPage) + && !query.isFetchingNextPage + && !(query.isLoading ?? false) + && !query.error + && !(guardOnFetching && (query.isFetching ?? false)) + + useEffect(() => { + if (!canLoad) + return + + if (!sentinelEl) + return + + if (!useWindow && !rootEl) + return + + if (typeof IntersectionObserver === 'undefined') + return + + const observer = new IntersectionObserver(([entry]) => { + const latest = latestRef.current + + if (!entry?.isIntersecting) + return + + if (!latest.enabled + || !latest.hasNextPage + || latest.isLoading + || latest.isFetchingNextPage + || latest.error + || (latest.guardOnFetching && latest.isFetching) + || loadingLockRef.current) { + return + } + + loadingLockRef.current = true + + const nextPage = latest.fetchNextPage({ + cancelRefetch: latest.cancelRefetch, + }) + + void Promise.resolve(nextPage).finally(() => { + loadingLockRef.current = false + }) + }, { + root: useWindow ? null : rootEl, + rootMargin, + threshold, + }) + + observer.observe(sentinelEl) + + return () => observer.disconnect() + }, [canLoad, rootEl, rootMargin, sentinelEl, threshold, useWindow]) + + return { + rootEl, + rootRef, + sentinelEl, + sentinelRef, + } +} From d0b2239c60507aa9da8862570e6d7f0ce51be952 Mon Sep 17 00:00:00 2001 From: kunal <96532810+Kunal152000@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:50:47 +0530 Subject: [PATCH 7/9] refactor: accept db.session explicitly in ApiKeyAuthService (#37832) Co-authored-by: kunalj1-arch Co-authored-by: Asuka Minato Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/auth/data_source_bearer_auth.py | 7 ++-- api/services/auth/api_key_auth_service.py | 24 ++++++------- .../auth/test_data_source_bearer_auth.py | 4 +-- .../auth/test_api_key_auth_service.py | 34 +++++++++---------- .../services/auth/test_auth_integration.py | 25 ++++++++------ .../auth/test_data_source_bearer_auth.py | 29 ++++++++++------ 6 files changed, 68 insertions(+), 55 deletions(-) diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index 1de206c73db..11fab84a831 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models +from extensions.ext_database import db from fields.base import ResponseModel from libs.login import login_required from services.auth.api_key_auth_service import ApiKeyAuthService @@ -58,7 +59,7 @@ class ApiKeyAuthDataSource(Resource): @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str): - data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(current_tenant_id) + data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(db.session(), current_tenant_id) if data_source_api_key_bindings: return { "sources": [ @@ -92,7 +93,7 @@ class ApiKeyAuthDataSourceBinding(Resource): data = payload.model_dump() ApiKeyAuthService.validate_api_key_auth_args(data) try: - ApiKeyAuthService.create_provider_auth(current_tenant_id, data) + ApiKeyAuthService.create_provider_auth(db.session(), current_tenant_id, data) except Exception as e: raise ApiKeyAuthFailedError(str(e)) return {"result": "success"}, 200 @@ -109,6 +110,6 @@ class ApiKeyAuthDataSourceBindingDelete(Resource): @with_current_tenant_id def delete(self, current_tenant_id: str, binding_id: UUID): # The role of the current user in the table must be admin or owner - ApiKeyAuthService.delete_provider_auth(current_tenant_id, str(binding_id)) + ApiKeyAuthService.delete_provider_auth(db.session(), current_tenant_id, str(binding_id)) return "", 204 diff --git a/api/services/auth/api_key_auth_service.py b/api/services/auth/api_key_auth_service.py index 36b15170567..42f1d4d8d40 100644 --- a/api/services/auth/api_key_auth_service.py +++ b/api/services/auth/api_key_auth_service.py @@ -2,17 +2,17 @@ import json from typing import Any from sqlalchemy import select +from sqlalchemy.orm import Session from core.helper import encrypter -from extensions.ext_database import db from models.source import DataSourceApiKeyAuthBinding from services.auth.api_key_auth_factory import ApiKeyAuthFactory class ApiKeyAuthService: @staticmethod - def get_provider_auth_list(tenant_id: str): - data_source_api_key_bindings = db.session.scalars( + def get_provider_auth_list(session: Session, tenant_id: str): + data_source_api_key_bindings = session.scalars( select(DataSourceApiKeyAuthBinding).where( DataSourceApiKeyAuthBinding.tenant_id == tenant_id, DataSourceApiKeyAuthBinding.disabled.is_(False) ) @@ -20,7 +20,7 @@ class ApiKeyAuthService: return data_source_api_key_bindings @staticmethod - def create_provider_auth(tenant_id: str, args: dict[str, Any]): + def create_provider_auth(session: Session, tenant_id: str, args: dict[str, Any]): auth_result = ApiKeyAuthFactory(args["provider"], args["credentials"]).validate_credentials() if auth_result: # Encrypt the api key @@ -31,12 +31,12 @@ class ApiKeyAuthService: tenant_id=tenant_id, category=args["category"], provider=args["provider"] ) data_source_api_key_binding.credentials = json.dumps(args["credentials"], ensure_ascii=False) - db.session.add(data_source_api_key_binding) - db.session.commit() + session.add(data_source_api_key_binding) + session.commit() @staticmethod - def get_auth_credentials(tenant_id: str, category: str, provider: str): - data_source_api_key_bindings = db.session.scalar( + def get_auth_credentials(session: Session, tenant_id: str, category: str, provider: str): + data_source_api_key_bindings = session.scalar( select(DataSourceApiKeyAuthBinding).where( DataSourceApiKeyAuthBinding.tenant_id == tenant_id, DataSourceApiKeyAuthBinding.category == category, @@ -52,16 +52,16 @@ class ApiKeyAuthService: return credentials @staticmethod - def delete_provider_auth(tenant_id: str, binding_id: str): - data_source_api_key_binding = db.session.scalar( + def delete_provider_auth(session: Session, tenant_id: str, binding_id: str): + data_source_api_key_binding = session.scalar( select(DataSourceApiKeyAuthBinding).where( DataSourceApiKeyAuthBinding.tenant_id == tenant_id, DataSourceApiKeyAuthBinding.id == binding_id, ) ) if data_source_api_key_binding: - db.session.delete(data_source_api_key_binding) - db.session.commit() + session.delete(data_source_api_key_binding) + session.commit() @classmethod def validate_api_key_auth_args(cls, args): diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py index 5eb9f71e695..e55b46d38bf 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py @@ -1,7 +1,7 @@ """Controller integration tests for API key data source auth routes.""" import json -from unittest.mock import patch +from unittest.mock import ANY, patch from flask.testing import FlaskClient from sqlalchemy import select @@ -85,7 +85,7 @@ def test_create_binding_successful( assert response.status_code == 200 assert response.get_json() == {"result": "success"} - create_auth.assert_called_once_with(tenant_id, payload) + create_auth.assert_called_once_with(ANY, tenant_id, payload) def test_create_binding_failure( diff --git a/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py b/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py index c93e61b2bfb..e2f8c8fc703 100644 --- a/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py @@ -51,7 +51,7 @@ class TestApiKeyAuthService: self._create_binding(db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider) db_session_with_containers.expire_all() - result = ApiKeyAuthService.get_provider_auth_list(tenant_id) + result = ApiKeyAuthService.get_provider_auth_list(db_session_with_containers, tenant_id) assert len(result) >= 1 tenant_results = [r for r in result if r.tenant_id == tenant_id] @@ -61,7 +61,7 @@ class TestApiKeyAuthService: def test_get_provider_auth_list_empty( self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id ): - result = ApiKeyAuthService.get_provider_auth_list(tenant_id) + result = ApiKeyAuthService.get_provider_auth_list(db_session_with_containers, tenant_id) tenant_results = [r for r in result if r.tenant_id == tenant_id] assert tenant_results == [] @@ -74,7 +74,7 @@ class TestApiKeyAuthService: ) db_session_with_containers.expire_all() - result = ApiKeyAuthService.get_provider_auth_list(tenant_id) + result = ApiKeyAuthService.get_provider_auth_list(db_session_with_containers, tenant_id) tenant_results = [r for r in result if r.tenant_id == tenant_id] assert tenant_results == [] @@ -95,7 +95,7 @@ class TestApiKeyAuthService: mock_factory.return_value = mock_auth_instance mock_encrypter.encrypt_token.return_value = "encrypted_test_key_123" - ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id, mock_args) mock_factory.assert_called_once() mock_auth_instance.validate_credentials.assert_called_once() @@ -118,7 +118,7 @@ class TestApiKeyAuthService: mock_auth_instance.validate_credentials.return_value = False mock_factory.return_value = mock_auth_instance - ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id, mock_args) db_session_with_containers.expire_all() bindings = db_session_with_containers.query(DataSourceApiKeyAuthBinding).filter_by(tenant_id=tenant_id).all() @@ -142,7 +142,7 @@ class TestApiKeyAuthService: original_key = mock_args["credentials"]["config"]["api_key"] - ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id, mock_args) assert mock_args["credentials"]["config"]["api_key"] == "encrypted_test_key_123" assert mock_args["credentials"]["config"]["api_key"] != original_key @@ -166,14 +166,14 @@ class TestApiKeyAuthService: ) db_session_with_containers.expire_all() - result = ApiKeyAuthService.get_auth_credentials(tenant_id, category, provider) + result = ApiKeyAuthService.get_auth_credentials(db_session_with_containers, tenant_id, category, provider) assert result == mock_credentials def test_get_auth_credentials_not_found( self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider ): - result = ApiKeyAuthService.get_auth_credentials(tenant_id, category, provider) + result = ApiKeyAuthService.get_auth_credentials(db_session_with_containers, tenant_id, category, provider) assert result is None @@ -190,7 +190,7 @@ class TestApiKeyAuthService: ) db_session_with_containers.expire_all() - result = ApiKeyAuthService.get_auth_credentials(tenant_id, category, provider) + result = ApiKeyAuthService.get_auth_credentials(db_session_with_containers, tenant_id, category, provider) assert result == special_credentials assert result["config"]["api_key"] == "key_with_中文_and_special_chars_!@#$%" @@ -204,7 +204,7 @@ class TestApiKeyAuthService: binding_id = binding.id db_session_with_containers.expire_all() - ApiKeyAuthService.delete_provider_auth(tenant_id, binding_id) + ApiKeyAuthService.delete_provider_auth(db_session_with_containers, tenant_id, binding_id) db_session_with_containers.expire_all() remaining = db_session_with_containers.query(DataSourceApiKeyAuthBinding).filter_by(id=binding_id).first() @@ -214,7 +214,7 @@ class TestApiKeyAuthService: self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id ): # Should not raise when binding not found - ApiKeyAuthService.delete_provider_auth(tenant_id, str(uuid4())) + ApiKeyAuthService.delete_provider_auth(db_session_with_containers, tenant_id, str(uuid4())) def test_validate_api_key_auth_args_success(self, mock_args): ApiKeyAuthService.validate_api_key_auth_args(mock_args) @@ -288,16 +288,16 @@ class TestApiKeyAuthService: mock_factory.return_value = mock_auth_instance mock_encrypter.encrypt_token.return_value = "encrypted_key" - with patch("services.auth.api_key_auth_service.db.session") as mock_session: - mock_session.commit.side_effect = Exception("Database error") - with pytest.raises(Exception, match="Database error"): - ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) + mock_session = MagicMock() + mock_session.commit.side_effect = Exception("Database error") + with pytest.raises(Exception, match="Database error"): + ApiKeyAuthService.create_provider_auth(mock_session, tenant_id, mock_args) @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") def test_create_provider_auth_factory_exception(self, mock_factory: MagicMock, tenant_id, mock_args): mock_factory.side_effect = Exception("Factory error") with pytest.raises(Exception, match="Factory error"): - ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) + ApiKeyAuthService.create_provider_auth(MagicMock(), tenant_id, mock_args) @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") @patch("services.auth.api_key_auth_service.encrypter") @@ -307,7 +307,7 @@ class TestApiKeyAuthService: mock_factory.return_value = mock_auth_instance mock_encrypter.encrypt_token.side_effect = Exception("Encryption error") with pytest.raises(Exception, match="Encryption error"): - ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) + ApiKeyAuthService.create_provider_auth(MagicMock(), tenant_id, mock_args) def test_validate_api_key_auth_args_none_input(self): with pytest.raises(TypeError): diff --git a/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py b/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py index 1de9ce38a0b..9b86ab41f2b 100644 --- a/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py +++ b/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py @@ -13,6 +13,7 @@ import pytest from flask import Flask from sqlalchemy.orm import Session +from extensions.ext_database import db from models.source import DataSourceApiKeyAuthBinding from services.auth.api_key_auth_factory import ApiKeyAuthFactory from services.auth.api_key_auth_service import ApiKeyAuthService @@ -56,7 +57,7 @@ class TestAuthIntegration: mock_encrypt.return_value = "encrypted_fc_test_key_123" args = {"category": category, "provider": AuthType.FIRECRAWL, "credentials": firecrawl_credentials} - ApiKeyAuthService.create_provider_auth(tenant_id_1, args) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id_1, args) mock_http.assert_called_once() call_args = mock_http.call_args @@ -100,15 +101,15 @@ class TestAuthIntegration: mock_encrypt.return_value = "encrypted_key" args1 = {"category": category, "provider": AuthType.FIRECRAWL, "credentials": firecrawl_credentials} - ApiKeyAuthService.create_provider_auth(tenant_id_1, args1) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id_1, args1) args2 = {"category": category, "provider": AuthType.JINA, "credentials": jina_credentials} - ApiKeyAuthService.create_provider_auth(tenant_id_2, args2) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id_2, args2) db_session_with_containers.expire_all() - result1 = ApiKeyAuthService.get_provider_auth_list(tenant_id_1) - result2 = ApiKeyAuthService.get_provider_auth_list(tenant_id_2) + result1 = ApiKeyAuthService.get_provider_auth_list(db_session_with_containers, tenant_id_1) + result2 = ApiKeyAuthService.get_provider_auth_list(db_session_with_containers, tenant_id_2) assert len(result1) == 1 assert result1[0].tenant_id == tenant_id_1 @@ -118,7 +119,9 @@ class TestAuthIntegration: def test_cross_tenant_access_prevention( self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id_2, category ): - result = ApiKeyAuthService.get_auth_credentials(tenant_id_2, category, AuthType.FIRECRAWL) + result = ApiKeyAuthService.get_auth_credentials( + db_session_with_containers, tenant_id_2, category, AuthType.FIRECRAWL + ) assert result is None @@ -160,7 +163,7 @@ class TestAuthIntegration: "provider": AuthType.FIRECRAWL, "credentials": {"auth_type": "bearer", "config": {"api_key": "fc_test_key_123"}}, } - ApiKeyAuthService.create_provider_auth(tenant_id_1, thread_args) + ApiKeyAuthService.create_provider_auth(db.session(), tenant_id_1, thread_args) results.append("success") except Exception as e: exceptions.append(e) @@ -213,7 +216,7 @@ class TestAuthIntegration: args = {"category": category, "provider": AuthType.FIRECRAWL, "credentials": firecrawl_credentials} with pytest.raises(httpx.RequestError): - ApiKeyAuthService.create_provider_auth(tenant_id_1, args) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id_1, args) db_session_with_containers.expire_all() bindings = db_session_with_containers.query(DataSourceApiKeyAuthBinding).filter_by(tenant_id=tenant_id_1).all() @@ -250,11 +253,13 @@ class TestAuthIntegration: mock_encrypt.return_value = "encrypted_key" args = {"category": category, "provider": AuthType.FIRECRAWL, "credentials": firecrawl_credentials} - ApiKeyAuthService.create_provider_auth(tenant_id_1, args) + ApiKeyAuthService.create_provider_auth(db_session_with_containers, tenant_id_1, args) db_session_with_containers.expire_all() - result = ApiKeyAuthService.get_auth_credentials(tenant_id_1, category, AuthType.FIRECRAWL) + result = ApiKeyAuthService.get_auth_credentials( + db_session_with_containers, tenant_id_1, category, AuthType.FIRECRAWL + ) assert result is not None assert result["config"]["api_key"] == "encrypted_key" diff --git a/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py b/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py index 7f449bb376e..21d1932f820 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime from inspect import unwrap from types import SimpleNamespace -from unittest.mock import PropertyMock, patch +from unittest.mock import ANY, PropertyMock, patch from controllers.console import console_ns from controllers.console.auth.data_source_bearer_auth import ( @@ -34,13 +34,16 @@ def test_list_data_source_auth_uses_injected_tenant_id() -> None: updated_at=datetime(2026, 1, 2, tzinfo=UTC), ) - with patch( - "controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.get_provider_auth_list", - return_value=[binding], - ) as get_provider_auth_list: + with ( + patch("controllers.console.auth.data_source_bearer_auth.db"), + patch( + "controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.get_provider_auth_list", + return_value=[binding], + ) as get_provider_auth_list, + ): result = method(api, "tenant-1") - get_provider_auth_list.assert_called_once_with("tenant-1") + get_provider_auth_list.assert_called_once_with(ANY, "tenant-1") assert result["sources"][0]["id"] == "binding-1" assert result["sources"][0]["provider"] == "custom" @@ -56,12 +59,13 @@ def test_create_data_source_auth_binding_uses_injected_tenant_id() -> None: with ( _payload_patch(payload), + patch("controllers.console.auth.data_source_bearer_auth.db"), patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args"), patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth") as create_auth, ): result, status = method(api, "tenant-1") - create_auth.assert_called_once_with("tenant-1", payload) + create_auth.assert_called_once_with(ANY, "tenant-1", payload) assert result == {"result": "success"} assert status == 200 @@ -70,11 +74,14 @@ def test_delete_data_source_auth_binding_uses_injected_tenant_id() -> None: api = ApiKeyAuthDataSourceBindingDelete() method = unwrap(api.delete) - with patch( - "controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.delete_provider_auth" - ) as delete_provider_auth: + with ( + patch("controllers.console.auth.data_source_bearer_auth.db"), + patch( + "controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.delete_provider_auth" + ) as delete_provider_auth, + ): result, status = method(api, "tenant-1", "binding-1") - delete_provider_auth.assert_called_once_with("tenant-1", "binding-1") + delete_provider_auth.assert_called_once_with(ANY, "tenant-1", "binding-1") assert result == "" assert status == 204 From 1eafbf9763e94c94db1c26b07dbfaa8c984c50d5 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 24 Jun 2026 11:35:12 +0800 Subject: [PATCH 8/9] fix(agent): save workflow agents as console roster apps (#37848) --- .../apps/agent_app/runtime_request_builder.py | 2 +- .../nodes/agent_v2/runtime_request_builder.py | 2 +- api/services/agent/composer_service.py | 61 +++++--- api/services/agent/roster_service.py | 1 + .../agent_app/test_runtime_request_builder.py | 16 +++ .../agent_v2/test_runtime_request_builder.py | 2 +- .../services/agent/test_agent_services.py | 133 +++++++++++++++++- 7 files changed, 195 insertions(+), 22 deletions(-) diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index fc1fcb0b168..9790f2fbca0 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -197,7 +197,7 @@ class AgentAppRuntimeRequestBuilder: def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str: """Return the transport plugin id expected by plugin-daemon headers.""" if plugin_id.count("/") == 1: - return plugin_id + return plugin_id.split(":", 1)[0].split("@", 1)[0] if plugin_id: return ModelProviderID(plugin_id).plugin_id return ModelProviderID(model_provider).plugin_id diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index e3c2dcee839..e5a541ed350 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -265,7 +265,7 @@ class WorkflowAgentRuntimeRequestBuilder: def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str: """Return the transport plugin id expected by plugin-daemon headers.""" if plugin_id.count("/") == 1: - return plugin_id + return plugin_id.split(":", 1)[0].split("@", 1)[0] if plugin_id: return ModelProviderID(plugin_id).plugin_id return ModelProviderID(model_provider).plugin_id diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 230475d5b2a..8c83ee80031 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -8,6 +8,7 @@ from sqlalchemy.sql.elements import ColumnElement from extensions.ext_database import db from libs.helper import to_timestamp +from models import Account from models.agent import ( Agent, AgentConfigRevision, @@ -36,6 +37,8 @@ from services.agent.errors import ( AgentVersionNotFoundError, InvalidComposerConfigError, ) +from services.agent.roster_service import AgentRosterService +from services.app_service import AppService, CreateAppParams from services.entities.agent_entities import ( AgentSoulConfig, ComposerCandidatesResponse, @@ -992,6 +995,14 @@ class AgentComposerService: operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, version_note=payload.version_note, ) + cls._copy_agent_drive_rows( + tenant_id=tenant_id, + source_agent_id=source_agent.id, + target_agent_id=roster_agent.id, + account_id=account_id, + agent_soul=agent_soul, + node_job=payload.node_job or WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), + ) binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT binding.agent_id = roster_agent.id binding.current_snapshot_id = roster_agent.active_config_snapshot_id @@ -1157,30 +1168,36 @@ class AgentComposerService: icon: str | None = None, icon_background: str | None = None, ) -> Agent: - agent = Agent( - tenant_id=tenant_id, - name=name, - description=description, - role=role, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - agent_kind=AgentKind.DIFY_AGENT, - scope=AgentScope.ROSTER, - source=AgentSource.WORKFLOW, - status=AgentStatus.ACTIVE, - created_by=account_id, - updated_by=account_id, - ) - db.session.add(agent) + account = cls._require_account(account_id=account_id) try: - db.session.flush() + app = AppService().create_app( + tenant_id, + CreateAppParams( + name=name, + description=description, + mode="agent", + agent_role=role, + icon_type=icon_type.value if isinstance(icon_type, AgentIconType) else icon_type, + icon=icon, + icon_background=icon_background, + ), + account, + ) except IntegrityError as exc: db.session.rollback() raise AgentNameConflictError() from exc - version = cls._create_config_version( + + agent = AgentRosterService(db.session).get_app_backing_agent(tenant_id=tenant_id, app_id=app.id) + if agent is None: + raise AgentNotFoundError() + + current_snapshot = cls._require_version( tenant_id=tenant_id, agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, account_id=account_id, agent_soul=agent_soul, operation=operation, @@ -1188,6 +1205,7 @@ class AgentComposerService: ) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.updated_by = account_id return agent @classmethod @@ -1316,6 +1334,13 @@ class AgentComposerService: raise AgentNotFoundError() return agent + @classmethod + def _require_account(cls, *, account_id: str) -> Account: + account = db.session.get(Account, account_id) + if not account: + raise ValueError("Account not found") + return account + @classmethod def _get_agent_if_present(cls, *, tenant_id: str, agent_id: str | None) -> Agent | None: if not agent_id: diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 6a9d5818647..97d91b50770 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -837,6 +837,7 @@ class AgentRosterService: if agent.source == AgentSource.AGENT_APP: return { AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } return { diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 0d1483e1b79..4f292d90bb4 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -144,6 +144,22 @@ class TestAgentAppRuntimeRequestBuilder: assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" + def test_build_normalizes_marketplace_model_plugin_id(self): + soul = _soul_with_model() + soul.model.plugin_id = ( + "langgenius/openai:0.4.2@21195ee1321849e0a7d4b3f6b2fd8c2be23ea6c7182e1b444ecc4c1711b52468" + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + llm = next(layer for layer in result.request.composition.layers if layer.name == "llm") + assert llm.config.plugin_id == "langgenius/openai" + assert llm.config.model_provider == "openai" + def test_build_maps_agent_soul_knowledge_to_knowledge_layer(self): soul = AgentSoulConfig.model_validate( { diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 78e49769159..ffa7ccdbca7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -189,7 +189,7 @@ def test_normalizes_langgenius_model_provider_for_agent_backend_transport(): context.snapshot.config_snapshot = AgentSoulConfig( prompt={"system_prompt": "You are careful."}, model=AgentSoulModelConfig( - plugin_id="langgenius/openai/openai", + plugin_id="langgenius/openai:0.4.2@21195ee1321849e0a7d4b3f6b2fd8c2be23ea6c7182e1b444ecc4c1711b52468", model_provider="langgenius/openai/openai", model="gpt-test", ), diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 1d0d5ed42c6..1bac183c39f 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime from types import SimpleNamespace import pytest +from sqlalchemy.exc import IntegrityError from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError from models.agent import ( @@ -32,7 +33,12 @@ from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import AgentVersionConflictError, InvalidComposerConfigError +from services.agent.errors import ( + AgentNameConflictError, + AgentNotFoundError, + AgentVersionConflictError, + InvalidComposerConfigError, +) from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.app_service import AppListParams, AppService @@ -427,6 +433,7 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk icon_background="#FFFFFF", ) create_roster_calls = [] + copy_drive_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) def fake_create_roster_agent_for_composer(**kwargs): @@ -438,6 +445,11 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk "_create_roster_agent_for_composer", fake_create_roster_agent_for_composer, ) + monkeypatch.setattr( + AgentComposerService, + "_copy_agent_drive_rows", + lambda **kwargs: copy_drive_calls.append(kwargs), + ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, @@ -533,6 +545,16 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk assert create_roster_calls[1]["role"] == "Copied role" assert create_roster_calls[1]["icon"] == "copied" assert create_roster_calls[1]["icon_background"] == "#E0F2FE" + assert copy_drive_calls == [ + { + "tenant_id": "tenant-1", + "source_agent_id": "roster-agent-1", + "target_agent_id": "roster-agent-1", + "account_id": "account-1", + "agent_soul": payload.agent_soul, + "node_job": payload.node_job, + } + ] def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch): @@ -1173,6 +1195,39 @@ def test_drive_copy_scopes_include_declared_output_benchmark_files(): def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) + created_apps = [] + backing_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Ready Agent", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id="app-agent-1", + active_config_snapshot_id="empty-version-1", + ) + + class FakeAppService: + def create_app(self, tenant_id, params, account): + created_apps.append((tenant_id, params, account)) + return SimpleNamespace(id="app-agent-1") + + class FakeAgentRosterService: + def __init__(self, session): + self.session = session + + def get_app_backing_agent(self, *, tenant_id, app_id): + assert tenant_id == "tenant-1" + assert app_id == "app-agent-1" + return backing_agent + + monkeypatch.setattr(composer_service, "AppService", FakeAppService) + monkeypatch.setattr(composer_service, "AgentRosterService", FakeAgentRosterService) + monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) + monkeypatch.setattr( + AgentComposerService, + "_require_version", + lambda **kwargs: SimpleNamespace(id="empty-version-1", tenant_id="tenant-1", agent_id="roster-agent-1"), + ) monkeypatch.setattr( AgentComposerService, "_create_config_version", @@ -1200,6 +1255,81 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes assert workflow_agent.active_config_has_model is True assert roster_agent.active_config_snapshot_id == "version-with-model" assert roster_agent.active_config_has_model is True + assert roster_agent.source == AgentSource.AGENT_APP + assert roster_agent.app_id == "app-agent-1" + created_tenant_id, created_params, created_account = created_apps[0] + assert created_tenant_id == "tenant-1" + assert created_params.mode == "agent" + assert created_params.name == "Ready Agent" + assert created_account.id == "account-1" + + +def test_composer_require_account(monkeypatch: pytest.MonkeyPatch): + account = SimpleNamespace(id="account-1") + monkeypatch.setattr(composer_service.db, "session", SimpleNamespace(get=lambda model, account_id: account)) + + assert AgentComposerService._require_account(account_id="account-1") is account + + +def test_composer_require_account_raises_when_missing(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(composer_service.db, "session", SimpleNamespace(get=lambda model, account_id: None)) + + with pytest.raises(ValueError, match="Account not found"): + AgentComposerService._require_account(account_id="missing-account") + + +def test_composer_create_roster_agent_rolls_back_name_conflict(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + + class FakeAppService: + def create_app(self, tenant_id, params, account): + raise IntegrityError("insert apps", params, Exception("duplicate")) + + monkeypatch.setattr(composer_service, "AppService", FakeAppService) + monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) + + with pytest.raises(AgentNameConflictError): + AgentComposerService._create_roster_agent_for_composer( + tenant_id="tenant-1", + account_id="account-1", + name="Duplicate Agent", + agent_soul=_agent_soul_with_model(), + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=None, + ) + + assert fake_session.rollbacks == 1 + + +def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + + class FakeAppService: + def create_app(self, tenant_id, params, account): + return SimpleNamespace(id="app-agent-1") + + class FakeAgentRosterService: + def __init__(self, session): + self.session = session + + def get_app_backing_agent(self, *, tenant_id, app_id): + return None + + monkeypatch.setattr(composer_service, "AppService", FakeAppService) + monkeypatch.setattr(composer_service, "AgentRosterService", FakeAgentRosterService) + monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) + + with pytest.raises(AgentNotFoundError): + AgentComposerService._create_roster_agent_for_composer( + tenant_id="tenant-1", + account_id="account-1", + name="Missing Backing Agent", + agent_soul=_agent_soul_with_model(), + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=None, + ) def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch): @@ -1773,6 +1903,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): assert agent_app_operations == { AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations From 24dd7ea3a8efdadb80c157242edeaf05f54f19bf Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:18:59 +0800 Subject: [PATCH 9/9] fix: add RBAC feature toggle to environment configuration (#37853) --- web/.env.example | 1 + web/__tests__/env.spec.ts | 30 +++++++++++++++++++ web/docker/entrypoint.sh | 1 + web/env.ts | 2 ++ .../__tests__/system-features.spec.ts | 4 +++ web/features/system-features/config.ts | 1 + 6 files changed, 39 insertions(+) diff --git a/web/.env.example b/web/.env.example index 112232e529c..7363ce628f3 100644 --- a/web/.env.example +++ b/web/.env.example @@ -116,3 +116,4 @@ NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=true NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=true NEXT_PUBLIC_ENABLE_TRIAL_APP=true NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=true +NEXT_PUBLIC_RBAC_ENABLED=false diff --git a/web/__tests__/env.spec.ts b/web/__tests__/env.spec.ts index 89781d32685..419dcadf030 100644 --- a/web/__tests__/env.spec.ts +++ b/web/__tests__/env.spec.ts @@ -1,5 +1,6 @@ describe('env runtime transport', () => { const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 + const originalRbacEnv = process.env.NEXT_PUBLIC_RBAC_ENABLED beforeEach(() => { vi.clearAllMocks() @@ -7,7 +8,9 @@ describe('env runtime transport', () => { vi.doUnmock('../utils/client') document.body.removeAttribute('data-enable-agent-v2') document.body.removeAttribute('data-enable-agent-v-2') + document.body.removeAttribute('data-rbac-enabled') delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 + delete process.env.NEXT_PUBLIC_RBAC_ENABLED }) afterAll(() => { @@ -15,6 +18,11 @@ describe('env runtime transport', () => { delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env + + if (originalRbacEnv === undefined) + delete process.env.NEXT_PUBLIC_RBAC_ENABLED + else + process.env.NEXT_PUBLIC_RBAC_ENABLED = originalRbacEnv }) it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => { @@ -25,6 +33,14 @@ describe('env runtime transport', () => { expect(env.NEXT_PUBLIC_ENABLE_AGENT_V2).toBe(true) }) + it('should read NEXT_PUBLIC_RBAC_ENABLED from the browser runtime dataset key', async () => { + document.body.setAttribute('data-rbac-enabled', 'true') + + const { env } = await import('../env') + + expect(env.NEXT_PUBLIC_RBAC_ENABLED).toBe(true) + }) + it('should emit the Agent v2 runtime dataset attribute from getDatasetMap on the server', async () => { process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = 'true' @@ -39,4 +55,18 @@ describe('env runtime transport', () => { expect(datasetMap['data-enable-agent-v2']).toBe(true) expect(datasetMap['data-enable-agent-v-2']).toBeUndefined() }) + + it('should emit the RBAC runtime dataset attribute from getDatasetMap on the server', async () => { + process.env.NEXT_PUBLIC_RBAC_ENABLED = 'true' + + vi.doMock('../utils/client', () => ({ + isClient: false, + isServer: true, + })) + + const { getDatasetMap } = await import('../env') + const datasetMap = getDatasetMap() + + expect(datasetMap['data-rbac-enabled']).toBe(true) + }) }) diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 31872154d70..48600464f69 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -42,6 +42,7 @@ export NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=${NEXT_PUBLIC_ENABLE_CHANGE_EMAIL:-${ENAB export NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=${NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED:-${CREATORS_PLATFORM_FEATURES_ENABLED}} export NEXT_PUBLIC_ENABLE_TRIAL_APP=${NEXT_PUBLIC_ENABLE_TRIAL_APP:-${ENABLE_TRIAL_APP}} export NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=${NEXT_PUBLIC_ENABLE_EXPLORE_BANNER:-${ENABLE_EXPLORE_BANNER}} +export NEXT_PUBLIC_RBAC_ENABLED=${NEXT_PUBLIC_RBAC_ENABLED:-${RBAC_ENABLED}} export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS} export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST} diff --git a/web/env.ts b/web/env.ts index 01610267b6d..30f32aa2a4a 100644 --- a/web/env.ts +++ b/web/env.ts @@ -87,6 +87,7 @@ const clientSchema = { NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: coercedBoolean.default(true), NEXT_PUBLIC_ENABLE_TRIAL_APP: coercedBoolean.default(true), NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: coercedBoolean.default(true), + NEXT_PUBLIC_RBAC_ENABLED: coercedBoolean.default(false), /** * Enable inline LaTeX rendering with single dollar signs ($...$) @@ -216,6 +217,7 @@ export const env = createEnv({ NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'), NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP : getRuntimeEnvFromBody('enableTrialApp'), NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER : getRuntimeEnvFromBody('enableExploreBanner'), + NEXT_PUBLIC_RBAC_ENABLED: isServer ? process.env.NEXT_PUBLIC_RBAC_ENABLED : getRuntimeEnvFromBody('rbacEnabled'), NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'), NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL : getRuntimeEnvFromBody('enableWebsiteFirecrawl'), diff --git a/web/features/system-features/__tests__/system-features.spec.ts b/web/features/system-features/__tests__/system-features.spec.ts index fa0938fb12e..78b94cc94f6 100644 --- a/web/features/system-features/__tests__/system-features.spec.ts +++ b/web/features/system-features/__tests__/system-features.spec.ts @@ -22,6 +22,7 @@ const defaultCloudEnv = { NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: true, NEXT_PUBLIC_ENABLE_TRIAL_APP: true, NEXT_PUBLIC_IS_EMAIL_SETUP: true, + NEXT_PUBLIC_RBAC_ENABLED: false, } const queryKey = ['console', 'systemFeatures'] as const @@ -139,6 +140,7 @@ describe('systemFeaturesQueryOptions', () => { enable_email_password_login: false, enable_social_oauth_login: true, enable_trial_app: true, + rbac_enabled: false, }) }) @@ -151,6 +153,7 @@ describe('systemFeaturesQueryOptions', () => { NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: true, NEXT_PUBLIC_ALLOW_REGISTER: false, NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: false, + NEXT_PUBLIC_RBAC_ENABLED: true, }, }) @@ -163,6 +166,7 @@ describe('systemFeaturesQueryOptions', () => { enable_collaboration_mode: true, is_allow_register: false, enable_explore_banner: false, + rbac_enabled: true, branding: { enabled: false, application_title: '', diff --git a/web/features/system-features/config.ts b/web/features/system-features/config.ts index 1b0c88a9ea2..0c5d91af5bf 100644 --- a/web/features/system-features/config.ts +++ b/web/features/system-features/config.ts @@ -101,4 +101,5 @@ export const cloudSystemFeatures = { enable_creators_platform: env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED, enable_trial_app: env.NEXT_PUBLIC_ENABLE_TRIAL_APP, enable_explore_banner: env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER, + rbac_enabled: env.NEXT_PUBLIC_RBAC_ENABLED, } satisfies GetSystemFeaturesResponse