mirror of
https://github.com/langgenius/dify.git
synced 2026-02-03 17:41:35 +08:00
feat: sandbox provider configuration
This commit is contained in:
parent
5b01f544d1
commit
15c3d712d3
@ -126,6 +126,7 @@ from .workspace import (
|
||||
model_providers,
|
||||
models,
|
||||
plugin,
|
||||
sandbox_providers,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
workspace,
|
||||
@ -191,6 +192,7 @@ __all__ = [
|
||||
"rag_pipeline_import",
|
||||
"rag_pipeline_workflow",
|
||||
"recommended_app",
|
||||
"sandbox_providers",
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
|
||||
139
api/controllers/console/workspace/sandbox_providers.py
Normal file
139
api/controllers/console/workspace/sandbox_providers.py
Normal file
@ -0,0 +1,139 @@
|
||||
import logging
|
||||
|
||||
from flask_restx import Resource, fields, reqparse
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.sandbox.sandbox_provider_service import SandboxProviderService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/sandbox-providers")
|
||||
class SandboxProviderListApi(Resource):
|
||||
"""List all sandbox providers for the current tenant."""
|
||||
|
||||
@console_ns.doc("list_sandbox_providers")
|
||||
@console_ns.doc(description="Get list of available sandbox providers with configuration status")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
fields.List(fields.Raw(description="Sandbox provider information")),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
"""List all sandbox providers."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
providers = SandboxProviderService.list_providers(current_tenant_id)
|
||||
return jsonable_encoder([p.model_dump() for p in providers])
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/sandbox-provider/<string:provider_type>")
|
||||
class SandboxProviderApi(Resource):
|
||||
"""Get specific sandbox provider details."""
|
||||
|
||||
@console_ns.doc("get_sandbox_provider")
|
||||
@console_ns.doc(description="Get specific sandbox provider details")
|
||||
@console_ns.doc(params={"provider_type": "Sandbox provider type (e2b, docker, local)"})
|
||||
@console_ns.response(200, "Success", fields.Raw(description="Sandbox provider details"))
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, provider_type: str):
|
||||
"""Get a specific sandbox provider."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
provider = SandboxProviderService.get_provider(current_tenant_id, provider_type)
|
||||
if not provider:
|
||||
return {"message": f"Provider {provider_type} not found"}, 404
|
||||
return jsonable_encoder(provider.model_dump())
|
||||
|
||||
|
||||
config_parser = reqparse.RequestParser()
|
||||
config_parser.add_argument("config", type=dict, required=True, location="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/sandbox-provider/<string:provider_type>/config")
|
||||
class SandboxProviderConfigApi(Resource):
|
||||
@console_ns.doc("save_sandbox_provider_config")
|
||||
@console_ns.doc(description="Save or update configuration for a sandbox provider")
|
||||
@console_ns.expect(config_parser)
|
||||
@console_ns.response(200, "Success")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self, provider_type: str):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
args = config_parser.parse_args()
|
||||
|
||||
try:
|
||||
result = SandboxProviderService.save_config(
|
||||
tenant_id=current_tenant_id,
|
||||
provider_type=provider_type,
|
||||
config=args["config"],
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
@console_ns.doc("delete_sandbox_provider_config")
|
||||
@console_ns.doc(description="Delete configuration for a sandbox provider")
|
||||
@console_ns.response(200, "Success")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def delete(self, provider_type: str):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
try:
|
||||
result = SandboxProviderService.delete_config(
|
||||
tenant_id=current_tenant_id,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/sandbox-provider/<string:provider_type>/activate")
|
||||
class SandboxProviderActivateApi(Resource):
|
||||
"""Activate a sandbox provider."""
|
||||
|
||||
@console_ns.doc("activate_sandbox_provider")
|
||||
@console_ns.doc(description="Activate a sandbox provider for the current workspace")
|
||||
@console_ns.response(200, "Success")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self, provider_type: str):
|
||||
"""Activate a sandbox provider."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
try:
|
||||
result = SandboxProviderService.activate_provider(
|
||||
tenant_id=current_tenant_id,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/sandbox-provider/active")
|
||||
class SandboxProviderActiveApi(Resource):
|
||||
"""Get the currently active sandbox provider."""
|
||||
|
||||
@console_ns.doc("get_active_sandbox_provider")
|
||||
@console_ns.doc(description="Get the currently active sandbox provider for the workspace")
|
||||
@console_ns.response(200, "Success")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
"""Get the active sandbox provider."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
active_provider = SandboxProviderService.get_active_provider(current_tenant_id)
|
||||
return {"provider_type": active_provider}
|
||||
@ -515,9 +515,9 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
# FIXME: Consolidate runtime config checking into a unified location.
|
||||
runtime = workflow.features_dict.get("runtime")
|
||||
graph_engine_layers = ()
|
||||
graph_engine_layers: tuple = ()
|
||||
if isinstance(runtime, dict) and runtime.get("enabled"):
|
||||
graph_engine_layers = (SandboxLayer(),)
|
||||
graph_engine_layers = (SandboxLayer(tenant_id=application_generate_entity.app_config.tenant_id),)
|
||||
|
||||
# Determine system_user_id based on invocation source
|
||||
is_external_api_call = application_generate_entity.invoke_from in {
|
||||
|
||||
@ -491,7 +491,10 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
# FIXME: Consolidate runtime config checking into a unified location.
|
||||
runtime = workflow.features_dict.get("runtime")
|
||||
if isinstance(runtime, dict) and runtime.get("enabled"):
|
||||
graph_engine_layers = (*graph_engine_layers, SandboxLayer())
|
||||
graph_engine_layers = (
|
||||
*graph_engine_layers,
|
||||
SandboxLayer(tenant_id=application_generate_entity.app_config.tenant_id),
|
||||
)
|
||||
|
||||
# Determine system_user_id based on invocation source
|
||||
is_external_api_call = application_generate_entity.invoke_from in {
|
||||
|
||||
@ -32,6 +32,10 @@ class SandboxLayer(GraphEngineLayer):
|
||||
- on_graph_end: Release the sandbox environment (cleanup)
|
||||
|
||||
Example:
|
||||
# Using tenant-specific configuration (recommended):
|
||||
layer = SandboxLayer(tenant_id="tenant-uuid")
|
||||
|
||||
# Using explicit configuration (for testing/override):
|
||||
layer = SandboxLayer(
|
||||
sandbox_type=SandboxType.DOCKER,
|
||||
options={"docker_image": "python:3.11-slim"},
|
||||
@ -44,8 +48,8 @@ class SandboxLayer(GraphEngineLayer):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# TODO: read from db table
|
||||
sandbox_type: SandboxType = SandboxType.DOCKER,
|
||||
tenant_id: str | None = None,
|
||||
sandbox_type: SandboxType | None = None,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
environments: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
@ -53,11 +57,17 @@ class SandboxLayer(GraphEngineLayer):
|
||||
Initialize the SandboxLayer.
|
||||
|
||||
Args:
|
||||
sandbox_type: Type of sandbox to create (default: DOCKER)
|
||||
options: Sandbox-specific configuration options
|
||||
environments: Environment variables to set in the sandbox
|
||||
tenant_id: Tenant ID to load sandbox configuration from database.
|
||||
If provided, sandbox_type and options are ignored and
|
||||
loaded from the tenant's active sandbox provider.
|
||||
sandbox_type: Type of sandbox to create (default: DOCKER).
|
||||
Only used if tenant_id is not provided.
|
||||
options: Sandbox-specific configuration options.
|
||||
Only used if tenant_id is not provided.
|
||||
environments: Environment variables to set in the sandbox.
|
||||
"""
|
||||
super().__init__()
|
||||
self._tenant_id = tenant_id
|
||||
self._sandbox_type = sandbox_type
|
||||
self._options: Mapping[str, Any] = options or {}
|
||||
self._environments: Mapping[str, str] = environments or {}
|
||||
@ -82,17 +92,33 @@ class SandboxLayer(GraphEngineLayer):
|
||||
"""
|
||||
Initialize the sandbox when workflow execution starts.
|
||||
|
||||
If tenant_id was provided, uses SandboxProviderService to create
|
||||
the sandbox with the tenant's active provider configuration.
|
||||
Otherwise, falls back to explicit sandbox_type/options.
|
||||
|
||||
Raises:
|
||||
SandboxInitializationError: If sandbox cannot be created
|
||||
"""
|
||||
logger.info("Initializing sandbox, sandbox_type=%s", self._sandbox_type)
|
||||
|
||||
try:
|
||||
self._sandbox = SandboxFactory.create(
|
||||
sandbox_type=self._sandbox_type,
|
||||
options=self._options,
|
||||
environments=self._environments,
|
||||
)
|
||||
if self._tenant_id:
|
||||
# Use SandboxProviderService to create sandbox based on tenant config
|
||||
from services.sandbox.sandbox_provider_service import SandboxProviderService
|
||||
|
||||
logger.info("Initializing sandbox for tenant_id=%s", self._tenant_id)
|
||||
self._sandbox = SandboxProviderService.create_sandbox(
|
||||
tenant_id=self._tenant_id,
|
||||
environments=self._environments,
|
||||
)
|
||||
else:
|
||||
# Fallback to explicit configuration (backward compatibility)
|
||||
sandbox_type = self._sandbox_type or SandboxType.DOCKER
|
||||
logger.info("Initializing sandbox, sandbox_type=%s", sandbox_type)
|
||||
self._sandbox = SandboxFactory.create(
|
||||
sandbox_type=sandbox_type,
|
||||
options=self._options,
|
||||
environments=self._environments,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Sandbox initialized, sandbox_id=%s, sandbox_arch=%s",
|
||||
self._sandbox.metadata.id,
|
||||
@ -100,7 +126,7 @@ class SandboxLayer(GraphEngineLayer):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to initialize sandbox")
|
||||
raise SandboxInitializationError(f"Failed to initialize {self._sandbox_type} sandbox: {e}") from e
|
||||
raise SandboxInitializationError(f"Failed to initialize sandbox: {e}") from e
|
||||
|
||||
def on_event(self, event: GraphEngineEvent) -> None:
|
||||
"""
|
||||
|
||||
@ -14,3 +14,9 @@ class NotSupportedOperationError(Exception):
|
||||
"""Exception raised when an operation is not supported."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SandboxConfigValidationError(ValueError):
|
||||
"""Exception raised when sandbox configuration validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
@ -135,6 +135,16 @@ class VirtualEnvironment(ABC):
|
||||
After exuection, the 3 handles will be closed by caller.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate(cls, options: Mapping[str, Any]) -> None:
|
||||
"""
|
||||
Validate that options can connect to the provider.
|
||||
|
||||
Raises:
|
||||
SandboxConfigValidationError: If validation fails
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_command_status(self, connection_handle: ConnectionHandle, pid: str) -> CommandStatus:
|
||||
"""
|
||||
|
||||
@ -76,3 +76,8 @@ class SandboxFactory:
|
||||
return LocalVirtualEnvironment
|
||||
case _:
|
||||
raise ValueError(f"Unsupported sandbox type: {sandbox_type}")
|
||||
|
||||
@classmethod
|
||||
def validate(cls, sandbox_type: SandboxType, options: Mapping[str, Any]) -> None:
|
||||
sandbox_class = cls._get_sandbox_class(sandbox_type)
|
||||
sandbox_class.validate(options)
|
||||
|
||||
@ -13,7 +13,7 @@ from docker.models.containers import Container
|
||||
|
||||
import docker
|
||||
from core.virtual_environment.__base.entities import Arch, CommandStatus, ConnectionHandle, FileState, Metadata
|
||||
from core.virtual_environment.__base.exec import VirtualEnvironmentLaunchFailedError
|
||||
from core.virtual_environment.__base.exec import SandboxConfigValidationError, VirtualEnvironmentLaunchFailedError
|
||||
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
|
||||
from core.virtual_environment.channel.exec import TransportEOFError
|
||||
from core.virtual_environment.channel.socket_transport import SocketWriteCloser
|
||||
@ -204,6 +204,15 @@ class DockerDaemonEnvironment(VirtualEnvironment):
|
||||
DOCKER_IMAGE = "docker_image"
|
||||
DOCKER_COMMAND = "docker_command"
|
||||
|
||||
@classmethod
|
||||
def validate(cls, options: Mapping[str, Any]) -> None:
|
||||
docker_sock = options.get(cls.OptionsKey.DOCKER_SOCK, cls._DEFAULT_DOCKER_SOCK)
|
||||
try:
|
||||
client = docker.DockerClient(base_url=docker_sock)
|
||||
client.ping()
|
||||
except docker.errors.DockerException as e:
|
||||
raise SandboxConfigValidationError(f"Docker connection failed: {e}") from e
|
||||
|
||||
def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata:
|
||||
"""
|
||||
Construct the Docker daemon virtual environment.
|
||||
|
||||
@ -11,7 +11,11 @@ from uuid import uuid4
|
||||
from e2b_code_interpreter import Sandbox # type: ignore[import-untyped]
|
||||
|
||||
from core.virtual_environment.__base.entities import Arch, CommandStatus, ConnectionHandle, FileState, Metadata
|
||||
from core.virtual_environment.__base.exec import ArchNotSupportedError, NotSupportedOperationError
|
||||
from core.virtual_environment.__base.exec import (
|
||||
ArchNotSupportedError,
|
||||
NotSupportedOperationError,
|
||||
SandboxConfigValidationError,
|
||||
)
|
||||
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
|
||||
from core.virtual_environment.channel.queue_transport import QueueTransportReadCloser
|
||||
from core.virtual_environment.channel.transport import (
|
||||
@ -85,6 +89,21 @@ class E2BEnvironment(VirtualEnvironment):
|
||||
class StoreKey(StrEnum):
|
||||
SANDBOX = "sandbox"
|
||||
|
||||
@classmethod
|
||||
def validate(cls, options: Mapping[str, Any]) -> None:
|
||||
from e2b.exceptions import AuthenticationException # type: ignore[import-untyped]
|
||||
|
||||
api_key = options.get(cls.OptionsKey.API_KEY, "")
|
||||
if not api_key:
|
||||
raise SandboxConfigValidationError("E2B API key is required")
|
||||
|
||||
try:
|
||||
Sandbox.list(api_key=api_key, limit=1).next_items()
|
||||
except AuthenticationException as e:
|
||||
raise SandboxConfigValidationError(f"E2B authentication failed: {e}") from e
|
||||
except Exception as e:
|
||||
raise SandboxConfigValidationError(f"E2B connection failed: {e}") from e
|
||||
|
||||
def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata:
|
||||
"""
|
||||
Construct a new E2B virtual environment.
|
||||
|
||||
@ -65,6 +65,10 @@ class LocalVirtualEnvironment(VirtualEnvironment):
|
||||
NEVER USE IT IN PRODUCTION ENVIRONMENTS.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def validate(cls, options: Mapping[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata:
|
||||
"""
|
||||
Construct the local virtual environment.
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
"""sandbox_providers
|
||||
|
||||
Revision ID: aab323465866
|
||||
Revises: 03ea244985ce
|
||||
Create Date: 2026-01-08 10:31:05.062722
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'aab323465866'
|
||||
down_revision = '03ea244985ce'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('sandbox_provider_system_config',
|
||||
sa.Column('id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False, comment='e2b, docker, local'),
|
||||
sa.Column('encrypted_config', models.types.LongText(), nullable=False, comment='Encrypted config JSON'),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='sandbox_provider_system_config_pkey'),
|
||||
sa.UniqueConstraint('provider_type', name='unique_sandbox_provider_system_config_type')
|
||||
)
|
||||
op.create_table('sandbox_providers',
|
||||
sa.Column('id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False, comment='e2b, docker, local'),
|
||||
sa.Column('encrypted_config', models.types.LongText(), nullable=False, comment='Encrypted config JSON'),
|
||||
sa.Column('is_active', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='sandbox_provider_pkey'),
|
||||
sa.UniqueConstraint('tenant_id', 'provider_type', name='unique_sandbox_provider_tenant_type')
|
||||
)
|
||||
with op.batch_alter_table('sandbox_providers', schema=None) as batch_op:
|
||||
batch_op.create_index('idx_sandbox_providers_tenant_active', ['tenant_id', 'is_active'], unique=False)
|
||||
batch_op.create_index('idx_sandbox_providers_tenant_id', ['tenant_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('sandbox_providers', schema=None) as batch_op:
|
||||
batch_op.drop_index('idx_sandbox_providers_tenant_id')
|
||||
batch_op.drop_index('idx_sandbox_providers_tenant_active')
|
||||
|
||||
op.drop_table('sandbox_providers')
|
||||
op.drop_table('sandbox_provider_system_config')
|
||||
# ### end Alembic commands ###
|
||||
@ -75,6 +75,7 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .sandbox import SandboxProvider, SandboxProviderSystemConfig
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
@ -170,6 +171,8 @@ __all__ = [
|
||||
"ProviderQuotaType",
|
||||
"ProviderType",
|
||||
"RecommendedApp",
|
||||
"SandboxProvider",
|
||||
"SandboxProviderSystemConfig",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"Tag",
|
||||
|
||||
83
api/models/sandbox.py
Normal file
83
api/models/sandbox.py
Normal file
@ -0,0 +1,83 @@
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import TypeBase
|
||||
from .types import LongText, StringUUID
|
||||
|
||||
|
||||
class SandboxProviderSystemConfig(TypeBase):
|
||||
"""
|
||||
System-level sandbox provider configuration.
|
||||
Stores default configuration for each provider type.
|
||||
"""
|
||||
|
||||
__tablename__ = "sandbox_provider_system_config"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="sandbox_provider_system_config_pkey"),
|
||||
sa.UniqueConstraint("provider_type", name="unique_sandbox_provider_system_config_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="e2b, docker, local")
|
||||
encrypted_config: Mapped[str] = mapped_column(LongText, nullable=False, comment="Encrypted config JSON")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
server_onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def config(self) -> Mapping[str, Any]:
|
||||
return cast(Mapping[str, Any], json.loads(self.encrypted_config or "{}"))
|
||||
|
||||
|
||||
class SandboxProvider(TypeBase):
|
||||
"""
|
||||
Tenant-level sandbox provider configuration.
|
||||
Each tenant can have one configuration per provider type.
|
||||
Only one provider can be active at a time per tenant.
|
||||
"""
|
||||
|
||||
__tablename__ = "sandbox_providers"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="sandbox_provider_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "provider_type", name="unique_sandbox_provider_tenant_type"),
|
||||
sa.Index("idx_sandbox_providers_tenant_id", "tenant_id"),
|
||||
sa.Index("idx_sandbox_providers_tenant_active", "tenant_id", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="e2b, docker, local")
|
||||
encrypted_config: Mapped[str] = mapped_column(LongText, nullable=False, comment="Encrypted config JSON")
|
||||
is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
server_onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def config(self) -> Mapping[str, Any]:
|
||||
return cast(Mapping[str, Any], json.loads(self.encrypted_config or "{}"))
|
||||
3
api/services/sandbox/__init__.py
Normal file
3
api/services/sandbox/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .sandbox_provider_service import SandboxProviderService
|
||||
|
||||
__all__ = ["SandboxProviderService"]
|
||||
48
api/services/sandbox/encryption.py
Normal file
48
api/services/sandbox/encryption.py
Normal file
@ -0,0 +1,48 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.entities.provider_entities import BasicProviderConfig
|
||||
from core.helper.provider_cache import ProviderCredentialsCache
|
||||
from core.helper.provider_encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter
|
||||
|
||||
|
||||
class SandboxProviderConfigCache(ProviderCredentialsCache):
|
||||
def __init__(self, tenant_id: str, provider_type: str):
|
||||
super().__init__(tenant_id=tenant_id, provider_type=provider_type)
|
||||
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
tenant_id = kwargs["tenant_id"]
|
||||
provider_type = kwargs["provider_type"]
|
||||
return f"sandbox_config:tenant_id:{tenant_id}:provider_type:{provider_type}"
|
||||
|
||||
|
||||
def create_sandbox_config_encrypter(
|
||||
tenant_id: str,
|
||||
config_schema: list[BasicProviderConfig],
|
||||
provider_type: str,
|
||||
) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]:
|
||||
cache = SandboxProviderConfigCache(tenant_id=tenant_id, provider_type=provider_type)
|
||||
return create_provider_encrypter(tenant_id=tenant_id, config=config_schema, cache=cache)
|
||||
|
||||
|
||||
def masked_config(
|
||||
schemas: list[BasicProviderConfig],
|
||||
config: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
masked = dict(config)
|
||||
configs = {x.name: x for x in schemas}
|
||||
for key, value in config.items():
|
||||
schema = configs.get(key)
|
||||
if not schema:
|
||||
masked[key] = value
|
||||
continue
|
||||
if schema.type == BasicProviderConfig.Type.SECRET_INPUT:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
if len(value) <= 4:
|
||||
masked[key] = "*" * len(value)
|
||||
else:
|
||||
masked[key] = value[:2] + "*" * (len(value) - 4) + value[-2:]
|
||||
else:
|
||||
masked[key] = value
|
||||
return masked
|
||||
368
api/services/sandbox/sandbox_provider_service.py
Normal file
368
api/services/sandbox/sandbox_provider_service.py
Normal file
@ -0,0 +1,368 @@
|
||||
"""
|
||||
Sandbox Provider Service for managing sandbox configurations.
|
||||
|
||||
Supports three provider types:
|
||||
- e2b: Cloud-based sandbox (requires API key)
|
||||
- docker: Local Docker-based sandbox (self-hosted)
|
||||
- local: Local execution without isolation (self-hosted only)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from constants import HIDDEN_VALUE
|
||||
from core.entities.provider_entities import BasicProviderConfig
|
||||
from core.tools.utils.system_oauth_encryption import (
|
||||
decrypt_system_oauth_params,
|
||||
)
|
||||
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
|
||||
from core.virtual_environment.factory import SandboxFactory, SandboxType
|
||||
from extensions.ext_database import db
|
||||
from models.sandbox import SandboxProvider, SandboxProviderSystemConfig
|
||||
from services.sandbox.encryption import create_sandbox_config_encrypter, masked_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxProviderType(StrEnum):
|
||||
E2B = "e2b"
|
||||
DOCKER = "docker"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
class E2BConfig(BaseModel):
|
||||
api_key: str = ""
|
||||
e2b_api_url: str = "https://api.e2b.app"
|
||||
e2b_default_template: str = "code-interpreter-v1"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_required(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
if not values.get("api_key"):
|
||||
raise ValueError("api_key is required")
|
||||
return values
|
||||
|
||||
|
||||
class DockerConfig(BaseModel):
|
||||
docker_sock: str = "unix:///var/run/docker.sock"
|
||||
docker_image: str = "ubuntu:latest"
|
||||
|
||||
|
||||
class LocalConfig(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
|
||||
SandboxProviderType.E2B: E2BConfig,
|
||||
SandboxProviderType.DOCKER: DockerConfig,
|
||||
SandboxProviderType.LOCAL: LocalConfig,
|
||||
}
|
||||
|
||||
PROVIDER_CONFIG_SCHEMAS: dict[str, list[BasicProviderConfig]] = {
|
||||
SandboxProviderType.E2B: [
|
||||
BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name="api_key"),
|
||||
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="e2b_api_url"),
|
||||
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="e2b_default_template"),
|
||||
],
|
||||
SandboxProviderType.DOCKER: [
|
||||
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="docker_sock"),
|
||||
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="docker_image"),
|
||||
],
|
||||
SandboxProviderType.LOCAL: [],
|
||||
}
|
||||
|
||||
|
||||
class SandboxProviderInfo(BaseModel):
|
||||
provider_type: str = Field(..., description="Provider type identifier")
|
||||
label: str = Field(..., description="Display name")
|
||||
description: str = Field(..., description="Provider description")
|
||||
icon: str = Field(..., description="Icon identifier")
|
||||
is_system_configured: bool = Field(default=False, description="Whether system default is configured")
|
||||
is_tenant_configured: bool = Field(default=False, description="Whether tenant has custom config")
|
||||
is_active: bool = Field(default=False, description="Whether this provider is active for the tenant")
|
||||
config: Mapping[str, Any] = Field(default_factory=dict, description="Masked config")
|
||||
config_schema: list[dict[str, Any]] = Field(default_factory=list, description="Config form schema")
|
||||
|
||||
|
||||
PROVIDER_METADATA: dict[str, dict[str, str]] = {
|
||||
SandboxProviderType.E2B: {
|
||||
"label": "E2B",
|
||||
"description": "Cloud-based sandbox powered by E2B. Secure, scalable, and managed.",
|
||||
"icon": "e2b",
|
||||
},
|
||||
SandboxProviderType.DOCKER: {
|
||||
"label": "Docker",
|
||||
"description": "Local Docker-based sandbox. Requires Docker daemon running on the host.",
|
||||
"icon": "docker",
|
||||
},
|
||||
SandboxProviderType.LOCAL: {
|
||||
"label": "Local",
|
||||
"description": "Local execution without isolation. Only for development/testing.",
|
||||
"icon": "local",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SandboxProviderService:
|
||||
@classmethod
|
||||
def get_available_provider_types(cls) -> list[str]:
|
||||
providers = [SandboxProviderType.E2B, SandboxProviderType.DOCKER]
|
||||
if dify_config.EDITION == "SELF_HOSTED":
|
||||
providers.append(SandboxProviderType.LOCAL)
|
||||
return [provider.value for provider in providers]
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls, tenant_id: str) -> list[SandboxProviderInfo]:
|
||||
available_types = cls.get_available_provider_types()
|
||||
result: list[SandboxProviderInfo] = []
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
tenant_configs = session.query(SandboxProvider).filter(SandboxProvider.tenant_id == tenant_id).all()
|
||||
tenant_config_map = {cfg.provider_type: cfg for cfg in tenant_configs}
|
||||
|
||||
system_defaults = session.query(SandboxProviderSystemConfig).all()
|
||||
system_default_map = {cfg.provider_type: cfg for cfg in system_defaults}
|
||||
|
||||
for provider_type in available_types:
|
||||
metadata = PROVIDER_METADATA.get(provider_type, {})
|
||||
config_schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, [])
|
||||
|
||||
tenant_config = tenant_config_map.get(provider_type)
|
||||
system_default = system_default_map.get(provider_type)
|
||||
|
||||
config: Mapping[str, Any] = {}
|
||||
if tenant_config and tenant_config.config:
|
||||
schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, [])
|
||||
encrypter, _ = create_sandbox_config_encrypter(tenant_id, schema, provider_type)
|
||||
decrypted = encrypter.decrypt(tenant_config.config)
|
||||
config = masked_config(schema, decrypted)
|
||||
|
||||
result.append(
|
||||
SandboxProviderInfo(
|
||||
provider_type=provider_type,
|
||||
label=metadata.get("label", provider_type),
|
||||
description=metadata.get("description", ""),
|
||||
icon=metadata.get("icon", provider_type),
|
||||
is_system_configured=system_default is not None,
|
||||
is_tenant_configured=tenant_config is not None,
|
||||
is_active=tenant_config.is_active if tenant_config else False,
|
||||
config=config,
|
||||
config_schema=[{"name": c.name, "type": c.type.value} for c in config_schema],
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, tenant_id: str, provider_type: str) -> SandboxProviderInfo | None:
|
||||
if provider_type not in cls.get_available_provider_types():
|
||||
return None
|
||||
|
||||
providers = cls.list_providers(tenant_id)
|
||||
for provider in providers:
|
||||
if provider.provider_type == provider_type:
|
||||
return provider
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def validate_config(cls, provider_type: str, config: Mapping[str, Any]) -> None:
|
||||
model_class = PROVIDER_CONFIG_MODELS.get(provider_type)
|
||||
if model_class:
|
||||
model_class.model_validate(config)
|
||||
|
||||
SandboxFactory.validate(SandboxType(provider_type), config)
|
||||
|
||||
@classmethod
|
||||
def save_config(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
provider_type: str,
|
||||
config: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if provider_type not in cls.get_available_provider_types():
|
||||
raise ValueError(f"Invalid provider type: {provider_type}")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
existing = (
|
||||
session.query(SandboxProvider)
|
||||
.filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
SandboxProvider.provider_type == provider_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, [])
|
||||
encrypter, _ = create_sandbox_config_encrypter(tenant_id, schema, provider_type)
|
||||
|
||||
final_config = dict(config)
|
||||
if existing and existing.config:
|
||||
existing_config = encrypter.decrypt(existing.config)
|
||||
for key, value in final_config.items():
|
||||
if value == HIDDEN_VALUE:
|
||||
final_config[key] = existing_config.get(key, "")
|
||||
|
||||
cls.validate_config(provider_type, final_config)
|
||||
|
||||
encrypted = encrypter.encrypt(final_config)
|
||||
|
||||
if existing:
|
||||
existing.encrypted_config = json.dumps(encrypted)
|
||||
else:
|
||||
new_config = SandboxProvider(
|
||||
tenant_id=tenant_id,
|
||||
provider_type=provider_type,
|
||||
encrypted_config=json.dumps(encrypted),
|
||||
is_active=False,
|
||||
)
|
||||
session.add(new_config)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def delete_config(cls, tenant_id: str, provider_type: str) -> dict[str, Any]:
|
||||
with Session(db.engine) as session:
|
||||
config = (
|
||||
session.query(SandboxProvider)
|
||||
.filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
SandboxProvider.provider_type == provider_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not config:
|
||||
return {"result": "success"}
|
||||
|
||||
if config.is_active:
|
||||
raise ValueError("Cannot delete config for the active provider. Switch to another provider first.")
|
||||
|
||||
session.delete(config)
|
||||
session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def activate_provider(cls, tenant_id: str, provider_type: str) -> dict[str, Any]:
|
||||
if provider_type not in cls.get_available_provider_types():
|
||||
raise ValueError(f"Invalid provider type: {provider_type}")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
tenant_config = (
|
||||
session.query(SandboxProvider)
|
||||
.filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
SandboxProvider.provider_type == provider_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
system_default = (
|
||||
session.query(SandboxProviderSystemConfig)
|
||||
.filter(SandboxProviderSystemConfig.provider_type == provider_type)
|
||||
.first()
|
||||
)
|
||||
|
||||
config_schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, [])
|
||||
needs_config = len(config_schema) > 0
|
||||
|
||||
if needs_config and not tenant_config and not system_default:
|
||||
raise ValueError(f"Provider {provider_type} is not configured. Please add configuration first.")
|
||||
|
||||
session.query(SandboxProvider).filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
).update({"is_active": False})
|
||||
|
||||
if tenant_config:
|
||||
tenant_config.is_active = True
|
||||
else:
|
||||
new_config = SandboxProvider(
|
||||
tenant_id=tenant_id,
|
||||
provider_type=provider_type,
|
||||
encrypted_config=json.dumps({}),
|
||||
is_active=True,
|
||||
)
|
||||
session.add(new_config)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def get_active_provider(cls, tenant_id: str) -> str | None:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
config = (
|
||||
session.query(SandboxProvider)
|
||||
.filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
SandboxProvider.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return config.provider_type if config else None
|
||||
|
||||
@classmethod
|
||||
def create_sandbox(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
environments: Mapping[str, str] | None = None,
|
||||
) -> VirtualEnvironment:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
tenant_config = (
|
||||
session.query(SandboxProvider)
|
||||
.filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
SandboxProvider.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if tenant_config:
|
||||
provider_type = tenant_config.provider_type
|
||||
else:
|
||||
provider_type = (
|
||||
SandboxProviderType.DOCKER if dify_config.EDITION == "SELF_HOSTED" else SandboxProviderType.E2B
|
||||
)
|
||||
logger.warning(
|
||||
"No active sandbox provider for tenant %s, using default: %s",
|
||||
tenant_id,
|
||||
provider_type,
|
||||
)
|
||||
|
||||
# Get effective config: tenant config > system default > empty
|
||||
config: Mapping[str, Any] = {}
|
||||
provider_config = (
|
||||
session.query(SandboxProvider)
|
||||
.filter(
|
||||
SandboxProvider.tenant_id == tenant_id,
|
||||
SandboxProvider.provider_type == provider_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if provider_config and provider_config.config:
|
||||
schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, [])
|
||||
encrypter, _ = create_sandbox_config_encrypter(tenant_id, schema, provider_type)
|
||||
config = encrypter.decrypt(provider_config.config)
|
||||
else:
|
||||
system_default = (
|
||||
session.query(SandboxProviderSystemConfig)
|
||||
.filter(SandboxProviderSystemConfig.provider_type == provider_type)
|
||||
.first()
|
||||
)
|
||||
if system_default and system_default.encrypted_config:
|
||||
config = decrypt_system_oauth_params(system_default.encrypted_config)
|
||||
|
||||
return SandboxFactory.create(
|
||||
sandbox_type=SandboxType(provider_type),
|
||||
options=dict(config) if config else {},
|
||||
environments=environments or {},
|
||||
)
|
||||
@ -16,7 +16,6 @@ from core.file import File
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from core.variables import Variable
|
||||
from core.variables.variables import VariableUnion
|
||||
from core.virtual_environment.factory import SandboxFactory, SandboxType
|
||||
from core.workflow.entities import WorkflowNodeExecution
|
||||
from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from core.workflow.errors import WorkflowNodeRunFailedError
|
||||
@ -43,6 +42,7 @@ from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.billing_service import BillingService
|
||||
from services.enterprise.plugin_manager_service import PluginCredentialType
|
||||
from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededError, WorkflowHashNotEqualError
|
||||
from services.sandbox.sandbox_provider_service import SandboxProviderService
|
||||
from services.workflow.workflow_converter import WorkflowConverter
|
||||
|
||||
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
@ -701,7 +701,7 @@ class WorkflowService:
|
||||
runtime = draft_workflow.features_dict.get("runtime")
|
||||
sandbox = None
|
||||
if isinstance(runtime, dict) and runtime.get("enabled"):
|
||||
sandbox = SandboxFactory.create(sandbox_type=SandboxType.DOCKER)
|
||||
sandbox = SandboxProviderService.create_sandbox(tenant_id=draft_workflow.tenant_id)
|
||||
|
||||
try:
|
||||
node, generator = WorkflowEntry.single_step_run(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
|
||||
|
||||
export const ACCOUNT_SETTING_TAB = {
|
||||
SANDBOX_PROVIDER: 'sandbox-provider',
|
||||
PROVIDER: 'provider',
|
||||
MEMBERS: 'members',
|
||||
BILLING: 'billing',
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
RiBox3Fill,
|
||||
RiBox3Line,
|
||||
RiBrain2Fill,
|
||||
RiBrain2Line,
|
||||
RiCloseLine,
|
||||
@ -36,6 +38,7 @@ import DataSourcePage from './data-source-page-new'
|
||||
import LanguagePage from './language-page'
|
||||
import MembersPage from './members-page'
|
||||
import ModelProviderPage from './model-provider-page'
|
||||
import SandboxProviderPage from './sandbox-provider-page'
|
||||
|
||||
const iconClassName = `
|
||||
w-5 h-5 mr-2
|
||||
@ -79,6 +82,12 @@ export default function AccountSetting({
|
||||
icon: <RiBrain2Line className={iconClassName} />,
|
||||
activeIcon: <RiBrain2Fill className={iconClassName} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER,
|
||||
name: t('settings.sandboxProvider', { ns: 'common' }),
|
||||
icon: <RiBox3Line className={iconClassName} />,
|
||||
activeIcon: <RiBox3Fill className={iconClassName} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
name: t('settings.members', { ns: 'common' }),
|
||||
@ -239,6 +248,7 @@ export default function AccountSetting({
|
||||
</div>
|
||||
<div className="px-4 pt-2 sm:px-8">
|
||||
{activeMenu === 'provider' && <ModelProviderPage searchText={searchValue} />}
|
||||
{activeMenu === 'sandbox-provider' && <SandboxProviderPage />}
|
||||
{activeMenu === 'members' && <MembersPage />}
|
||||
{activeMenu === 'billing' && <BillingPage />}
|
||||
{activeMenu === 'data-source' && <DataSourcePage />}
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { memo, useCallback, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import {
|
||||
useDeleteSandboxProviderConfig,
|
||||
useInvalidSandboxProviderList,
|
||||
useSaveSandboxProviderConfig,
|
||||
} from '@/service/use-sandbox-provider'
|
||||
import { PROVIDER_DOC_LINKS, SANDBOX_FIELD_CONFIGS } from './constants'
|
||||
|
||||
type ConfigModalProps = {
|
||||
provider: SandboxProvider
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ConfigModal = ({
|
||||
provider,
|
||||
onClose,
|
||||
}: ConfigModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const invalidateList = useInvalidSandboxProviderList()
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
|
||||
const { mutateAsync: saveConfig, isPending: isSaving } = useSaveSandboxProviderConfig()
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting } = useDeleteSandboxProviderConfig()
|
||||
|
||||
const formSchemas: FormSchema[] = useMemo(() => {
|
||||
return provider.config_schema.map((schema) => {
|
||||
const fieldConfig = SANDBOX_FIELD_CONFIGS[schema.name as keyof typeof SANDBOX_FIELD_CONFIGS]
|
||||
const fallbackType = schema.type === 'secret' ? FormTypeEnum.secretInput : FormTypeEnum.textInput
|
||||
|
||||
return {
|
||||
name: schema.name,
|
||||
label: fieldConfig ? t(fieldConfig.labelKey, { ns: 'common' }) : schema.name,
|
||||
placeholder: fieldConfig ? t(fieldConfig.placeholderKey, { ns: 'common' }) : '',
|
||||
type: fieldConfig?.type ?? fallbackType,
|
||||
required: schema.name === 'api_key',
|
||||
default: provider.config[schema.name] || '',
|
||||
}
|
||||
})
|
||||
}, [provider.config_schema, provider.config, t])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const formValues = formRef.current?.getFormValues({
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
})
|
||||
|
||||
if (!formValues?.isCheckValidated)
|
||||
return
|
||||
|
||||
try {
|
||||
await saveConfig({
|
||||
providerType: provider.provider_type,
|
||||
config: formValues.values,
|
||||
})
|
||||
await invalidateList()
|
||||
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
// Error toast is handled by fetch layer
|
||||
}
|
||||
}, [saveConfig, provider.provider_type, invalidateList, notify, t, onClose])
|
||||
|
||||
const handleRevoke = useCallback(async () => {
|
||||
try {
|
||||
await deleteConfig(provider.provider_type)
|
||||
await invalidateList()
|
||||
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
// Error toast is handled by fetch layer
|
||||
}
|
||||
}, [deleteConfig, provider.provider_type, invalidateList, notify, t, onClose])
|
||||
|
||||
const isConfigured = provider.is_tenant_configured
|
||||
const docLink = PROVIDER_DOC_LINKS[provider.provider_type]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={onClose}
|
||||
title={t('sandboxProvider.configModal.title', { ns: 'common', provider: provider.label })}
|
||||
closable
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="mt-4">
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={formRef}
|
||||
labelClassName="system-sm-semibold mb-1 flex items-center gap-1 text-text-secondary"
|
||||
formClassName="space-y-4"
|
||||
/>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="system-sm-medium inline-flex items-center gap-1 text-text-accent hover:underline"
|
||||
>
|
||||
{t('sandboxProvider.configModal.readDoc', { ns: 'common' })}
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured && (
|
||||
<Button
|
||||
variant="warning"
|
||||
size="medium"
|
||||
onClick={handleRevoke}
|
||||
disabled={isDeleting || isSaving}
|
||||
>
|
||||
{t('sandboxProvider.configModal.revoke', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || isDeleting}
|
||||
>
|
||||
{t('sandboxProvider.configModal.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConfigModal)
|
||||
@ -0,0 +1,40 @@
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
|
||||
export const SANDBOX_FIELD_CONFIGS = {
|
||||
api_key: {
|
||||
labelKey: 'sandboxProvider.configModal.apiKey',
|
||||
placeholderKey: 'sandboxProvider.configModal.apiKeyPlaceholder',
|
||||
type: FormTypeEnum.secretInput,
|
||||
},
|
||||
e2b_api_url: {
|
||||
labelKey: 'sandboxProvider.configModal.e2bApiUrl',
|
||||
placeholderKey: 'sandboxProvider.configModal.e2bApiUrlPlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
e2b_default_template: {
|
||||
labelKey: 'sandboxProvider.configModal.e2bTemplate',
|
||||
placeholderKey: 'sandboxProvider.configModal.e2bTemplatePlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
docker_sock: {
|
||||
labelKey: 'sandboxProvider.configModal.dockerSock',
|
||||
placeholderKey: 'sandboxProvider.configModal.dockerSockPlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
docker_image: {
|
||||
labelKey: 'sandboxProvider.configModal.dockerImage',
|
||||
placeholderKey: 'sandboxProvider.configModal.dockerImagePlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
base_working_path: {
|
||||
labelKey: 'sandboxProvider.configModal.baseWorkingPath',
|
||||
placeholderKey: 'sandboxProvider.configModal.baseWorkingPathPlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PROVIDER_DOC_LINKS: Record<string, string> = {
|
||||
e2b: 'https://e2b.dev/docs',
|
||||
docker: 'https://docs.docker.com/',
|
||||
local: '',
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetSandboxProviderList } from '@/service/use-sandbox-provider'
|
||||
import ConfigModal from './config-modal'
|
||||
import ProviderCard from './provider-card'
|
||||
import SwitchModal from './switch-modal'
|
||||
|
||||
const SandboxProviderPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceOwner } = useAppContext()
|
||||
const { data: providers, isLoading } = useGetSandboxProviderList()
|
||||
|
||||
const [configModalProvider, setConfigModalProvider] = useState<SandboxProvider | null>(null)
|
||||
const [switchModalProvider, setSwitchModalProvider] = useState<SandboxProvider | null>(null)
|
||||
|
||||
const currentProvider = providers?.find(p => p.is_active)
|
||||
const otherProviders = providers?.filter(p => !p.is_active) || []
|
||||
|
||||
const handleConfig = (provider: SandboxProvider) => {
|
||||
setConfigModalProvider(provider)
|
||||
}
|
||||
|
||||
const handleEnable = (provider: SandboxProvider) => {
|
||||
setSwitchModalProvider(provider)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<div className="system-sm-regular text-text-tertiary">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Provider Section */}
|
||||
{currentProvider && (
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-2 text-text-secondary">
|
||||
{t('sandboxProvider.currentProvider', { ns: 'common' })}
|
||||
</div>
|
||||
<ProviderCard
|
||||
provider={currentProvider}
|
||||
isCurrent
|
||||
onConfig={() => handleConfig(currentProvider)}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Providers Section */}
|
||||
{otherProviders.length > 0 && (
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-2 text-text-secondary">
|
||||
{t('sandboxProvider.otherProvider', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{otherProviders.map(provider => (
|
||||
<ProviderCard
|
||||
key={provider.provider_type}
|
||||
provider={provider}
|
||||
onConfig={() => handleConfig(provider)}
|
||||
onEnable={() => handleEnable(provider)}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrentWorkspaceOwner && (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('sandboxProvider.noPermission', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Modal */}
|
||||
{configModalProvider && (
|
||||
<ConfigModal
|
||||
provider={configModalProvider}
|
||||
onClose={() => setConfigModalProvider(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Switch Modal */}
|
||||
{switchModalProvider && (
|
||||
<SwitchModal
|
||||
provider={switchModalProvider}
|
||||
onClose={() => setSwitchModalProvider(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SandboxProviderPage)
|
||||
@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ProviderCardProps = {
|
||||
provider: SandboxProvider
|
||||
isCurrent?: boolean
|
||||
onConfig: () => void
|
||||
onEnable?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, string> = {
|
||||
e2b: '/sandbox-providers/e2b.svg',
|
||||
docker: '/sandbox-providers/docker.svg',
|
||||
local: '/sandbox-providers/local.svg',
|
||||
}
|
||||
|
||||
const ProviderIcon = ({ providerType }: { providerType: string }) => {
|
||||
const iconSrc = PROVIDER_ICONS[providerType] || PROVIDER_ICONS.e2b
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt={`${providerType} icon`}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderCard = ({
|
||||
provider,
|
||||
isCurrent = false,
|
||||
onConfig,
|
||||
onEnable,
|
||||
disabled = false,
|
||||
}: ProviderCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isConfigured = provider.is_tenant_configured || provider.is_system_configured
|
||||
const showEnableButton = !isCurrent && isConfigured && onEnable
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between rounded-xl p-4',
|
||||
isCurrent ? 'bg-background-section' : 'bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{/* Icon */}
|
||||
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-divider-subtle bg-background-default-subtle">
|
||||
<ProviderIcon providerType={provider.provider_type} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-md-semibold text-text-primary">
|
||||
{provider.label}
|
||||
</span>
|
||||
{provider.is_system_configured && (
|
||||
<span className="system-2xs-medium-uppercase rounded border border-divider-regular px-1.5 py-0.5 text-text-tertiary">
|
||||
{t('sandboxProvider.managedBySaas', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{provider.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: Connected Badge + Actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isConfigured && (
|
||||
<span className="system-xs-medium flex items-center gap-1 rounded-md bg-util-colors-green-green-50 px-1.5 py-0.5 text-util-colors-green-green-600">
|
||||
<Indicator color="green" />
|
||||
{t('sandboxProvider.connected', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onConfig}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('sandboxProvider.config', { ns: 'common' })}
|
||||
</Button>
|
||||
{showEnableButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onEnable}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('sandboxProvider.enable', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProviderCard)
|
||||
@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { RiAlertLine } from '@remixicon/react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import {
|
||||
useActivateSandboxProvider,
|
||||
useInvalidSandboxProviderList,
|
||||
} from '@/service/use-sandbox-provider'
|
||||
|
||||
type SwitchModalProps = {
|
||||
provider: SandboxProvider
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SwitchModal = ({
|
||||
provider,
|
||||
onClose,
|
||||
}: SwitchModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const invalidateList = useInvalidSandboxProviderList()
|
||||
|
||||
const { mutateAsync: activateProvider, isPending } = useActivateSandboxProvider()
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
await activateProvider(provider.provider_type)
|
||||
await invalidateList()
|
||||
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
// Error toast is handled by fetch layer
|
||||
}
|
||||
}, [activateProvider, provider.provider_type, invalidateList, notify, t, onClose])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={onClose}
|
||||
title={t('sandboxProvider.switchModal.title', { ns: 'common' })}
|
||||
closable
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="mt-4">
|
||||
{/* Warning Section */}
|
||||
<div className="flex gap-3 rounded-xl bg-state-warning-hover p-3">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-state-warning-solid">
|
||||
<RiAlertLine className="h-3 w-3 text-text-warning-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('sandboxProvider.switchModal.warning', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="system-xs-regular mt-1 text-text-secondary">
|
||||
{t('sandboxProvider.switchModal.warningDesc', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Text */}
|
||||
<div className="system-sm-regular mt-4 text-text-secondary">
|
||||
{t('sandboxProvider.switchModal.confirmText', { ns: 'common', provider: provider.label })}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('sandboxProvider.switchModal.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('sandboxProvider.switchModal.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SwitchModal)
|
||||
@ -549,6 +549,49 @@
|
||||
"provider.saveFailed": "Save api key failed",
|
||||
"provider.validatedError": "Validation failed: ",
|
||||
"provider.validating": "Validating key...",
|
||||
"sandboxProvider.config": "Config",
|
||||
"sandboxProvider.configModal.apiEndpoint": "API Endpoint",
|
||||
"sandboxProvider.configModal.apiEndpointPlaceholder": "https://api.example.com/v1",
|
||||
"sandboxProvider.configModal.apiKey": "API Key / Secret",
|
||||
"sandboxProvider.configModal.apiKeyPlaceholder": "Enter your API key",
|
||||
"sandboxProvider.configModal.baseWorkingPath": "Base Working Path",
|
||||
"sandboxProvider.configModal.baseWorkingPathPlaceholder": "/tmp/sandbox",
|
||||
"sandboxProvider.configModal.cancel": "Cancel",
|
||||
"sandboxProvider.configModal.confirm": "Confirm",
|
||||
"sandboxProvider.configModal.dockerImage": "Docker Image",
|
||||
"sandboxProvider.configModal.dockerImagePlaceholder": "ubuntu:latest",
|
||||
"sandboxProvider.configModal.dockerSock": "Docker Socket",
|
||||
"sandboxProvider.configModal.dockerSockPlaceholder": "unix:///var/run/docker.sock",
|
||||
"sandboxProvider.configModal.e2bApiUrl": "E2B API URL",
|
||||
"sandboxProvider.configModal.e2bApiUrlPlaceholder": "https://api.e2b.app",
|
||||
"sandboxProvider.configModal.e2bTemplate": "E2B Template",
|
||||
"sandboxProvider.configModal.e2bTemplatePlaceholder": "code-interpreter-v1",
|
||||
"sandboxProvider.configModal.readDoc": "Read Documentation",
|
||||
"sandboxProvider.configModal.revoke": "Revoke",
|
||||
"sandboxProvider.configModal.save": "Save",
|
||||
"sandboxProvider.configModal.title": "Configure - {{provider}}",
|
||||
"sandboxProvider.connected": "Connected",
|
||||
"sandboxProvider.currentProvider": "Current Provider",
|
||||
"sandboxProvider.daytona.description": "Secure sandboxed cloud environments for AI agents. learn more",
|
||||
"sandboxProvider.daytona.label": "Daytona",
|
||||
"sandboxProvider.docker.description": "Secure sandboxed cloud environments for AI agents. learn more",
|
||||
"sandboxProvider.docker.label": "Docker",
|
||||
"sandboxProvider.e2b.description": "Secure sandboxed cloud environments for AI agents. learn more",
|
||||
"sandboxProvider.e2b.label": "E2B",
|
||||
"sandboxProvider.enable": "Enable",
|
||||
"sandboxProvider.local.description": "This mode will provide the host machine as an agent, and its use in production is not recommended.",
|
||||
"sandboxProvider.local.label": "Local",
|
||||
"sandboxProvider.managedBySaas": "Managed by SaaS",
|
||||
"sandboxProvider.noPermission": "Contact the workspace administrator to make changes.",
|
||||
"sandboxProvider.notConfigured": "Not Configured",
|
||||
"sandboxProvider.otherProvider": "Other Provider",
|
||||
"sandboxProvider.switchModal.cancel": "Cancel",
|
||||
"sandboxProvider.switchModal.confirm": "Switch Provider",
|
||||
"sandboxProvider.switchModal.confirmText": "You are about to switch the active sandbox provider to {{provider}}.",
|
||||
"sandboxProvider.switchModal.title": "Switch Active Provider?",
|
||||
"sandboxProvider.switchModal.warning": "Impact on running agents",
|
||||
"sandboxProvider.switchModal.warningDesc": "Switching the provider will affect all newly started agent tasks. Currently running sessions might be interrupted.",
|
||||
"sandboxProvider.title": "Sandbox Providers",
|
||||
"settings.account": "My account",
|
||||
"settings.accountGroup": "GENERAL",
|
||||
"settings.apiBasedExtension": "API Extension",
|
||||
@ -560,6 +603,7 @@
|
||||
"settings.members": "Members",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Model Provider",
|
||||
"settings.sandboxProvider": "Sandbox Provider",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
"tag.addNew": "Add new tag",
|
||||
"tag.addTag": "Add tags",
|
||||
|
||||
@ -549,6 +549,47 @@
|
||||
"provider.saveFailed": "API 密钥保存失败",
|
||||
"provider.validatedError": "校验失败:",
|
||||
"provider.validating": "验证密钥中...",
|
||||
"sandboxProvider.config": "配置",
|
||||
"sandboxProvider.configModal.apiEndpoint": "API 端点",
|
||||
"sandboxProvider.configModal.apiEndpointPlaceholder": "https://api.example.com/v1",
|
||||
"sandboxProvider.configModal.apiKey": "API Key / 密钥",
|
||||
"sandboxProvider.configModal.apiKeyPlaceholder": "输入您的 API Key",
|
||||
"sandboxProvider.configModal.cancel": "取消",
|
||||
"sandboxProvider.configModal.confirm": "确认",
|
||||
"sandboxProvider.configModal.dockerImage": "Docker 镜像",
|
||||
"sandboxProvider.configModal.dockerImagePlaceholder": "ubuntu:latest",
|
||||
"sandboxProvider.configModal.dockerSock": "Docker Socket",
|
||||
"sandboxProvider.configModal.dockerSockPlaceholder": "unix:///var/run/docker.sock",
|
||||
"sandboxProvider.configModal.e2bApiUrl": "E2B API 地址",
|
||||
"sandboxProvider.configModal.e2bApiUrlPlaceholder": "https://api.e2b.app",
|
||||
"sandboxProvider.configModal.e2bTemplate": "E2B 模板",
|
||||
"sandboxProvider.configModal.e2bTemplatePlaceholder": "code-interpreter-v1",
|
||||
"sandboxProvider.configModal.readDoc": "查看文档",
|
||||
"sandboxProvider.configModal.revoke": "撤销",
|
||||
"sandboxProvider.configModal.save": "保存",
|
||||
"sandboxProvider.configModal.title": "配置 - {{provider}}",
|
||||
"sandboxProvider.connected": "已连接",
|
||||
"sandboxProvider.currentProvider": "当前供应商",
|
||||
"sandboxProvider.daytona.description": "为 AI 代理提供安全的沙箱云环境。了解更多",
|
||||
"sandboxProvider.daytona.label": "Daytona",
|
||||
"sandboxProvider.docker.description": "为 AI 代理提供安全的沙箱云环境。了解更多",
|
||||
"sandboxProvider.docker.label": "Docker",
|
||||
"sandboxProvider.e2b.description": "为 AI 代理提供安全的沙箱云环境。了解更多",
|
||||
"sandboxProvider.e2b.label": "E2B",
|
||||
"sandboxProvider.enable": "启用",
|
||||
"sandboxProvider.local.description": "此模式将主机作为代理提供,不建议在生产环境中使用。",
|
||||
"sandboxProvider.local.label": "本地",
|
||||
"sandboxProvider.managedBySaas": "由 SaaS 管理",
|
||||
"sandboxProvider.noPermission": "请联系工作空间管理员进行更改。",
|
||||
"sandboxProvider.notConfigured": "未配置",
|
||||
"sandboxProvider.otherProvider": "其他供应商",
|
||||
"sandboxProvider.switchModal.cancel": "取消",
|
||||
"sandboxProvider.switchModal.confirm": "切换供应商",
|
||||
"sandboxProvider.switchModal.confirmText": "您即将将活动沙箱供应商切换为 {{provider}}。",
|
||||
"sandboxProvider.switchModal.title": "切换活动供应商?",
|
||||
"sandboxProvider.switchModal.warning": "对运行中的代理的影响",
|
||||
"sandboxProvider.switchModal.warningDesc": "切换供应商将影响所有新启动的代理任务。当前运行的会话可能会被中断。",
|
||||
"sandboxProvider.title": "沙箱供应商",
|
||||
"settings.account": "我的账户",
|
||||
"settings.accountGroup": "通用",
|
||||
"settings.apiBasedExtension": "API 扩展",
|
||||
@ -560,6 +601,7 @@
|
||||
"settings.members": "成员",
|
||||
"settings.plugin": "插件",
|
||||
"settings.provider": "模型供应商",
|
||||
"settings.sandboxProvider": "沙箱供应商",
|
||||
"settings.workplaceGroup": "工作空间",
|
||||
"tag.addNew": "创建新标签",
|
||||
"tag.addTag": "添加标签",
|
||||
|
||||
1
web/public/sandbox-providers/docker.svg
Normal file
1
web/public/sandbox-providers/docker.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" fill="#3A4D54" d="M73.8 50.8h11.3v11.5h5.7c2.6 0 5.3-.5 7.8-1.3 1.2-.4 2.6-1 3.8-1.7-1.6-2.1-2.4-4.7-2.6-7.3-.3-3.5.4-8.1 2.8-10.8l1.2-1.4 1.4 1.1c3.6 2.9 6.5 6.8 7.1 11.4 4.3-1.3 9.3-1 13.1 1.2l1.5.9-.8 1.6c-3.2 6.2-9.9 8.2-16.4 7.8-9.8 24.3-31 35.8-56.8 35.8-13.3 0-25.5-5-32.5-16.8l-.1-.2-1-2.1c-2.4-5.2-3.1-10.9-2.6-16.6l.2-1.7h9.6V50.8h11.3V39.6h22.5V28.3h13.5v22.5z"/><path fill="#00AADA" d="M110.4 55.1c.8-5.9-3.6-10.5-6.4-12.7-3.1 3.6-3.6 13.2 1.3 17.2-2.8 2.4-8.5 4.7-14.5 4.7H18.6c-.6 6.2.5 11.9 3 16.8l.8 1.5c.5.9 1.1 1.7 1.7 2.6 3 .2 5.7.3 8.2.2 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5 1.1-8.3 1.3h-.6c-1.3.1-2.7.1-4.2.1-1.6 0-3.1 0-4.9-.1 6 6.8 15.4 10.8 27.2 10.8 25 0 46.2-11.1 55.5-35.9 6.7.7 13.1-1 16-6.7-4.5-2.7-10.5-1.8-13.9-.1z"/><path fill="#28B8EB" d="M110.4 55.1c.8-5.9-3.6-10.5-6.4-12.7-3.1 3.6-3.6 13.2 1.3 17.2-2.8 2.4-8.5 4.7-14.5 4.7h-68c-.3 9.5 3.2 16.7 9.5 21 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5.2 1.2-8.5 1.4l-.1-.1c8.5 4.4 20.8 4.3 35-1.1 15.8-6.1 30.6-17.7 40.9-30.9-.2.1-.4.1-.5.2z"/><path fill="#028BB8" d="M18.7 71.8c.4 3.3 1.4 6.4 2.9 9.3l.8 1.5c.5.9 1.1 1.7 1.7 2.6 3 .2 5.7.3 8.2.2 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5.2 1.2-8.5 1.4h-.4c-1.3.1-2.7.1-4.1.1-1.6 0-3.2 0-4.9-.1 6 6.8 15.5 10.8 27.3 10.8 21.4 0 40-8.1 50.8-26H18.7v-.1z"/><path fill="#019BC6" d="M23.5 71.8c1.3 5.8 4.3 10.4 8.8 13.5 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5.2 1.2-8.6 1.4 8.5 4.4 20.8 4.3 34.9-1.1 8.5-3.3 16.8-8.2 24.2-14.1H23.5z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M28.4 52.7h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm3-12h9.8v9.8h-9.8v-9.8zm.9.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#23C2EE" d="M39.6 52.7h9.8v9.8h-9.8v-9.8zm.9.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M50.9 52.7h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#23C2EE" d="M50.9 41.5h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm3.1 10.4H72v9.8h-9.8v-9.8zm.8.8h.8v8.1H63v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M62.2 41.5H72v9.8h-9.8v-9.8zm.8.8h.8v8.1H63v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#23C2EE" d="M62.2 30.2H72V40h-9.8v-9.8zm.8.8h.8v8.1H63V31zm1.5 0h.8v8.1h-.8V31zm1.4 0h.8v8.1h-.8V31zm1.5 0h.8v8.1h-.8V31zm1.5 0h.8v8.1h-.8V31zm1.5 0h.8v8.1h-.8V31z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M73.5 52.7h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#D4EEF1" d="M48.8 78.3c1.5 0 2.7 1.2 2.7 2.7 0 1.5-1.2 2.7-2.7 2.7-1.5 0-2.7-1.2-2.7-2.7 0-1.5 1.2-2.7 2.7-2.7"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#3A4D54" d="M48.8 79.1c.2 0 .5 0 .7.1-.2.1-.4.4-.4.7 0 .4.4.8.8.8.3 0 .6-.2.7-.4.1.2.1.5.1.7 0 1.1-.9 1.9-1.9 1.9-1.1 0-1.9-.9-1.9-1.9 0-1 .8-1.9 1.9-1.9M1.1 72.8h125.4c-2.7-.7-8.6-1.6-7.7-5.2-5 5.7-16.9 4-20 1.2-3.4 4.9-23 3-24.3-.8-4.2 5-17.3 5-21.5 0-1.4 3.8-21 5.7-24.3.8-3 2.8-15 4.5-20-1.2 1.1 3.5-4.9 4.5-7.6 5.2"/><path fill="#BFDBE0" d="M56 97.8c-6.7-3.2-10.3-7.5-12.4-12.2-2.5.7-5.5 1.2-8.9 1.4-1.3.1-2.7.1-4.1.1-1.7 0-3.4 0-5.2-.1 6 6 13.6 10.7 27.5 10.8H56z"/><path fill="#D4EEF1" d="M46.1 89.9c-.9-1.3-1.8-2.8-2.5-4.3-2.5.7-5.5 1.2-8.9 1.4 2.3 1.2 5.7 2.4 11.4 2.9z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
3
web/public/sandbox-providers/e2b.svg
Normal file
3
web/public/sandbox-providers/e2b.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="128" height="128" viewBox="0 0 26 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8458 19.3029C21.6671 19.3029 21.5555 19.4963 21.6448 19.6511L23.5141 22.889C23.6175 23.0681 23.4528 23.2828 23.253 23.2293L17.5836 21.7101C17.3359 21.6437 17.0813 21.7907 17.0149 22.0384L15.4958 27.7079C15.4422 27.9077 15.1739 27.943 15.0705 27.7639L13.2008 24.5254C13.1115 24.3707 12.8881 24.3707 12.7987 24.5254L10.929 27.7639C10.8256 27.943 10.5573 27.9077 10.5038 27.7079L8.9846 22.0384C8.91824 21.7907 8.66365 21.6437 8.41597 21.7101L2.74652 23.2293C2.54675 23.2828 2.38199 23.0681 2.4854 22.889L4.35472 19.6511C4.44406 19.4963 4.33238 19.3029 4.15368 19.3029L0.415222 19.3028C0.208406 19.3028 0.104834 19.0528 0.251077 18.9066L4.40145 14.7563C4.58277 14.5749 4.58277 14.281 4.40145 14.0997L0.251079 9.94927C0.104837 9.80302 0.208414 9.55297 0.415232 9.55297L4.15328 9.55302C4.33198 9.55302 4.44368 9.35957 4.35433 9.20481L2.4854 5.96763C2.38199 5.78852 2.54676 5.5738 2.74652 5.62733L8.41597 7.14652C8.66365 7.21288 8.91824 7.0659 8.98461 6.81822L10.5038 1.14869C10.5573 0.948918 10.8256 0.913592 10.929 1.0927L12.7987 4.33116C12.8881 4.48593 13.1114 4.48593 13.2008 4.33116L15.0705 1.0927C15.1739 0.913592 15.4422 0.948917 15.4957 1.14869L17.0149 6.81822C17.0813 7.0659 17.3359 7.21288 17.5835 7.14652L23.253 5.62733C23.4528 5.5738 23.6175 5.78852 23.5141 5.96763L21.6452 9.20481C21.5558 9.35957 21.6675 9.55302 21.8462 9.55302L25.5844 9.55297C25.7912 9.55297 25.8948 9.80302 25.7486 9.94927L21.5982 14.0997C21.4169 14.281 21.4169 14.5749 21.5982 14.7563L25.7486 18.9066C25.8948 19.0528 25.7912 19.3028 25.5844 19.3028L21.8458 19.3029ZM20.419 10.404C20.5869 10.236 20.4241 9.9541 20.1947 10.0156L15.1461 11.3684C14.8984 11.4348 14.6438 11.2878 14.5775 11.0401L13.224 5.98888C13.1625 5.75947 12.837 5.75947 12.7755 5.98888L11.422 11.0401C11.3557 11.2878 11.1011 11.4348 10.8534 11.3684L5.80496 10.0156C5.57555 9.95414 5.41278 10.2361 5.58072 10.404L9.27643 14.0997C9.45774 14.281 9.45774 14.575 9.27643 14.7563L5.57985 18.4528C5.41191 18.6208 5.57467 18.9027 5.80409 18.8412L10.8534 17.4882C11.1011 17.4218 11.3557 17.5688 11.422 17.8165L12.7755 22.8677C12.837 23.0972 13.1625 23.0972 13.224 22.8677L14.5775 17.8165C14.6439 17.5688 14.8984 17.4218 15.1461 17.4882L20.1956 18.8413C20.425 18.9027 20.5878 18.6208 20.4198 18.4529L16.7232 14.7563C16.5419 14.575 16.5419 14.281 16.7232 14.0997L20.419 10.404Z" fill="#FF8800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
8
web/public/sandbox-providers/local.svg
Normal file
8
web/public/sandbox-providers/local.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect x="8" y="16" width="112" height="96" rx="8" fill="#374151"/>
|
||||
<rect x="16" y="24" width="96" height="80" rx="4" fill="#1F2937"/>
|
||||
<text x="28" y="56" fill="#10B981" font-family="monospace" font-size="20" font-weight="bold">$</text>
|
||||
<rect x="44" y="42" width="48" height="4" rx="2" fill="#10B981"/>
|
||||
<rect x="28" y="68" width="64" height="4" rx="2" fill="#6B7280"/>
|
||||
<rect x="28" y="84" width="40" height="4" rx="2" fill="#6B7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
96
web/service/use-sandbox-provider.ts
Normal file
96
web/service/use-sandbox-provider.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { del, get, post } from './base'
|
||||
import { useInvalid } from './use-base'
|
||||
|
||||
const NAME_SPACE = 'sandbox-provider'
|
||||
|
||||
export type ConfigSchema = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type SandboxProvider = {
|
||||
provider_type: string
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
is_system_configured: boolean
|
||||
is_tenant_configured: boolean
|
||||
is_active: boolean
|
||||
config: Record<string, string>
|
||||
config_schema: ConfigSchema[]
|
||||
}
|
||||
|
||||
export const useGetSandboxProviderList = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'list'],
|
||||
queryFn: () => get<SandboxProvider[]>('/workspaces/current/sandbox-providers'),
|
||||
retry: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvalidSandboxProviderList = () => {
|
||||
return useInvalid([NAME_SPACE, 'list'])
|
||||
}
|
||||
|
||||
export const useGetSandboxProvider = (providerType: string) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'provider', providerType],
|
||||
queryFn: () => get<SandboxProvider>(`/workspaces/current/sandbox-provider/${providerType}`),
|
||||
retry: 0,
|
||||
enabled: !!providerType,
|
||||
})
|
||||
}
|
||||
|
||||
export const useSaveSandboxProviderConfig = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'save-config'],
|
||||
mutationFn: ({ providerType, config }: { providerType: string, config: Record<string, string> }) => {
|
||||
return post<{ result: string }>(`/workspaces/current/sandbox-provider/${providerType}/config`, {
|
||||
body: { config },
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'list'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteSandboxProviderConfig = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'delete-config'],
|
||||
mutationFn: (providerType: string) => {
|
||||
return del<{ result: string }>(`/workspaces/current/sandbox-provider/${providerType}/config`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'list'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useActivateSandboxProvider = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'activate'],
|
||||
mutationFn: (providerType: string) => {
|
||||
return post<{ result: string }>(`/workspaces/current/sandbox-provider/${providerType}/activate`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'list'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetActiveSandboxProvider = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'active'],
|
||||
queryFn: () => get<{ provider_type: string | null }>('/workspaces/current/sandbox-provider/active'),
|
||||
retry: 0,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user