fix(box): restore sandbox config and shared mcp runtime

This commit is contained in:
Junyan Qin
2026-05-12 23:24:02 +08:00
parent afc37958c1
commit e4c674a9f0
25 changed files with 758 additions and 547 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import os
import sys
import typing
@@ -13,6 +14,7 @@ from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
from langbot_plugin.box.actions import LangBotToBoxAction
from ..utils import platform
from ..utils.managed_runtime import ManagedRuntimeConnector
@@ -27,6 +29,10 @@ _DEFAULT_PORT = 5410
_HEARTBEAT_INTERVAL_SEC = 20
# Top-level keys under ``box`` that are LangBot-internal and should not be
# forwarded to the Box runtime.
_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'})
def _get_box_config(ap) -> dict:
"""Return the 'box' section from instance config, with safe fallbacks."""
@@ -35,6 +41,15 @@ def _get_box_config(ap) -> dict:
return config_data.get('box', {})
def _get_runtime_endpoint(box_cfg: dict) -> str:
runtime_cfg = box_cfg.get('runtime') or {}
return str(runtime_cfg.get('endpoint', '')).strip()
def _filter_config_for_runtime(box_cfg: dict) -> dict:
return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS}
def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
"""Derive the WS relay base URL used for managed-process attach.
@@ -43,10 +58,19 @@ def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
"""
box_cfg = _get_box_config(ap)
# Explicit relay URL takes precedence.
runtime_url = str(box_cfg.get('runtime_url', '')).strip()
if runtime_url:
return runtime_url
# Explicit runtime endpoint takes precedence. The config value is a base
# URL; endpoint-specific paths are appended by the SDK client.
endpoint = _get_runtime_endpoint(box_cfg)
if endpoint:
parsed = urlparse(endpoint)
scheme = parsed.scheme or 'ws'
if scheme == 'ws':
scheme = 'http'
elif scheme == 'wss':
scheme = 'https'
host = parsed.hostname or '127.0.0.1'
port = parsed.port or _DEFAULT_PORT
return f'{scheme}://{host}:{port}'
# In Docker, relay lives on the box runtime container.
if platform.get_platform() == 'docker':
@@ -59,7 +83,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Connect to the Box runtime via action RPC.
Transport decision (mirrors Plugin runtime logic):
1. Docker / --standalone-box / explicit runtime_url -> WebSocket to external Box process
1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process
2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe)
3. Unix / macOS -> subprocess + stdio pipe
"""
@@ -74,7 +98,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.configured_runtime_url = self._load_configured_runtime_url()
self.configured_runtime_endpoint = self._load_configured_runtime_endpoint()
self.ws_relay_base_url = resolve_box_ws_relay_url(ap)
self.client = ActionRPCBoxClient(logger=ap.logger)
@@ -87,6 +111,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
parsed = urlparse(self.ws_relay_base_url)
self._relay_host = parsed.hostname or '127.0.0.1'
self._relay_port = parsed.port or _DEFAULT_PORT
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
def _uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime.
@@ -94,17 +119,17 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
True when:
- Running inside Docker (Box runtime is a separate container)
- The ``--standalone-box`` CLI flag was passed
- An explicit ``runtime_url`` was configured
- An explicit ``runtime.endpoint`` was configured
"""
return bool(
self.configured_runtime_url
self.configured_runtime_endpoint
or platform.get_platform() == 'docker'
or platform.use_websocket_to_connect_box_runtime()
)
async def initialize(self) -> None:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_url:
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
@@ -141,6 +166,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self.ap.logger.info('Use stdio to connect to box runtime')
python_path = sys.executable
env = os.environ.copy()
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
connected = asyncio.Event()
connect_error: list[Exception] = []
@@ -168,12 +195,20 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
await self._start_runtime_subprocess(
env = os.environ.copy()
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
python_path = sys.executable
self.runtime_subprocess = await asyncio.create_subprocess_exec(
python_path,
'-m',
'langbot_plugin.box.server',
'--ws-control-port',
str(self._relay_port),
env=env,
)
self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait())
ws_url = f'ws://localhost:{self._relay_port}/rpc/ws'
await self._connect_ws(ws_url, '(windows) WebSocket')
@@ -191,8 +226,15 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
All endpoints share a single port; action RPC is at ``/rpc/ws``.
"""
if self.configured_runtime_url:
return self.configured_runtime_url
if self.configured_runtime_endpoint:
base = self.configured_runtime_endpoint.rstrip('/')
parsed = urlparse(base)
scheme = parsed.scheme or 'ws'
if scheme in ('http', 'https'):
scheme = 'wss' if scheme == 'https' else 'ws'
host = parsed.hostname or '127.0.0.1'
port = parsed.port or _DEFAULT_PORT
return f'{scheme}://{host}:{port}/rpc/ws'
if platform.get_platform() == 'docker':
return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws'
@@ -242,6 +284,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._handler_task = asyncio.create_task(handler.run())
try:
await handler.call_action(CommonAction.PING, {})
if self._filtered_box_config:
await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config)
self.ap.logger.debug('Sent box configuration to Box runtime via INIT.')
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
connected.set()
await self._handler_task
@@ -292,5 +337,5 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
# -- config helpers ------------------------------------------------------
def _load_configured_runtime_url(self) -> str:
return str(_get_box_config(self.ap).get('runtime_url', '')).strip()
def _load_configured_runtime_endpoint(self) -> str:
return _get_runtime_endpoint(_get_box_config(self.ap))

View File

@@ -28,11 +28,6 @@ _MAX_RECENT_ERRORS = 50
_MIB = 1024 * 1024
def _is_path_under(path: str, root: str) -> bool:
"""Check whether *path* equals *root* or is a child of *root*."""
return path == root or path.startswith(f'{root}{os.sep}')
def _is_path_under(path: str, root: str) -> bool:
"""Check whether *path* equals *root* or is a child of *root*."""
return path == root or path.startswith(f'{root}{os.sep}')
@@ -57,9 +52,9 @@ class BoxService:
client = self._runtime_connector.client
self.client = client
self.output_limit_chars = output_limit_chars
self.shared_host_root = self._load_shared_host_root()
self.allowed_host_mount_roots = self._load_allowed_host_mount_roots()
self.default_host_workspace = self._load_default_host_workspace()
self.host_root = self._load_host_root()
self.allowed_mount_roots = self._load_allowed_mount_roots()
self.default_workspace = self._load_default_workspace()
self.profile = self._load_profile()
self.custom_image = self._load_custom_image()
self.workspace_quota_mb = self._load_workspace_quota_mb()
@@ -70,7 +65,7 @@ class BoxService:
self._reconnecting = False
async def initialize(self):
self._ensure_default_host_workspace()
self._ensure_default_workspace()
try:
if self._runtime_connector is not None:
await self._runtime_connector.initialize()
@@ -80,7 +75,7 @@ class BoxService:
self._connector_error = ''
self.ap.logger.info(
f'LangBot Box runtime initialized: profile={self.profile.name} '
f'default_workspace={self.default_host_workspace or "(none)"}'
f'default_workspace={self.default_workspace or "(none)"}'
)
except Exception as exc:
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
@@ -134,7 +129,7 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Podman or Docker to use sandbox features.')
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
try:
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
except BoxError as exc:
@@ -251,8 +246,8 @@ class BoxService:
def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec:
spec_payload = dict(spec_payload)
spec_payload.setdefault('env', {})
if spec_payload.get('host_path') in (None, '') and self.default_host_workspace is not None:
spec_payload['host_path'] = self.default_host_workspace
if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None:
spec_payload['host_path'] = self.default_workspace
if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None:
spec_payload['workspace_quota_mb'] = self.workspace_quota_mb
@@ -280,10 +275,10 @@ class BoxService:
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
return await self.client.start_managed_process(session_id, process_spec)
async def get_managed_process(self, session_id: str) -> BoxManagedProcessInfo:
return await self.client.get_managed_process(session_id)
async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
return await self.client.get_managed_process(session_id, process_id)
def get_managed_process_websocket_url(self, session_id: str) -> str:
def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str:
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
if getter is None:
raise BoxValidationError('box runtime client does not support managed process websocket attach')
@@ -292,7 +287,7 @@ class BoxService:
if self._runtime_connector is not None
else 'http://127.0.0.1:5410'
)
return getter(session_id, ws_relay_base_url)
return getter(session_id, ws_relay_base_url, process_id)
def _serialize_result(self, result: BoxExecutionResult) -> dict:
stdout, stdout_truncated = self._truncate(result.stdout)
@@ -382,8 +377,11 @@ class BoxService:
'stderr_preview': stderr_preview,
}
def _load_allowed_host_mount_roots(self) -> list[str]:
configured_roots = _get_box_config(self.ap).get('allowed_host_mount_roots', [])
def _local_config(self) -> dict:
return _get_box_config(self.ap).get('local') or {}
def _load_allowed_mount_roots(self) -> list[str]:
configured_roots = self._local_config().get('allowed_mount_roots', [])
normalized_roots: list[str] = []
for root in configured_roots:
@@ -392,31 +390,31 @@ class BoxService:
continue
normalized_roots.append(os.path.realpath(os.path.abspath(root_value)))
if not normalized_roots and self.shared_host_root is not None:
normalized_roots.append(self.shared_host_root)
if not normalized_roots and self.host_root is not None:
normalized_roots.append(self.host_root)
return normalized_roots
def _load_shared_host_root(self) -> str | None:
shared_host_root = str(_get_box_config(self.ap).get('shared_host_root', '')).strip()
if not shared_host_root:
def _load_host_root(self) -> str | None:
host_root = str(self._local_config().get('host_root', '')).strip()
if not host_root:
return None
return os.path.realpath(os.path.abspath(shared_host_root))
return os.path.realpath(os.path.abspath(host_root))
def _load_default_host_workspace(self) -> str | None:
default_host_workspace = str(_get_box_config(self.ap).get('default_host_workspace', '')).strip()
if not default_host_workspace:
if self.shared_host_root is None:
def _load_default_workspace(self) -> str | None:
default_workspace = str(self._local_config().get('default_workspace', '')).strip()
if not default_workspace:
if self.host_root is None:
return None
default_host_workspace = os.path.join(self.shared_host_root, 'default')
return os.path.realpath(os.path.abspath(default_host_workspace))
default_workspace = os.path.join(self.host_root, 'default')
return os.path.realpath(os.path.abspath(default_workspace))
def _load_custom_image(self) -> str | None:
raw = str(_get_box_config(self.ap).get('image', '') or '').strip()
raw = str(self._local_config().get('image', '') or '').strip()
return raw or None
def _load_workspace_quota_mb(self) -> int | None:
raw_value = _get_box_config(self.ap).get('workspace_quota_mb')
raw_value = self._local_config().get('workspace_quota_mb')
if raw_value in (None, ''):
return None
try:
@@ -427,28 +425,28 @@ class BoxService:
raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0')
return value
def _ensure_default_host_workspace(self):
if self.default_host_workspace is None:
def _ensure_default_workspace(self):
if self.default_workspace is None:
return
if os.path.isdir(self.default_host_workspace):
if os.path.isdir(self.default_workspace):
return
if os.path.exists(self.default_host_workspace):
raise BoxValidationError('default_host_workspace must point to a directory on the host')
if os.path.exists(self.default_workspace):
raise BoxValidationError('box.local.default_workspace must point to a directory on the host')
if not self.allowed_host_mount_roots:
if not self.allowed_mount_roots:
raise BoxValidationError(
'default_host_workspace cannot be created because no allowed_host_mount_roots are configured'
'box.local.default_workspace cannot be created because no allowed_mount_roots are configured'
)
for allowed_root in self.allowed_host_mount_roots:
if _is_path_under(self.default_host_workspace, allowed_root):
os.makedirs(self.default_host_workspace, exist_ok=True)
for allowed_root in self.allowed_mount_roots:
if _is_path_under(self.default_workspace, allowed_root):
os.makedirs(self.default_workspace, exist_ok=True)
return
allowed_roots = ', '.join(self.allowed_host_mount_roots)
raise BoxValidationError(f'default_host_workspace is outside allowed_host_mount_roots: {allowed_roots}')
allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
def _validate_host_mount(self, spec: BoxSpec):
if spec.host_path is None:
@@ -458,20 +456,18 @@ class BoxService:
if not os.path.isdir(host_path):
raise BoxValidationError('host_path must point to an existing directory on the host')
if not self.allowed_host_mount_roots:
raise BoxValidationError(
'host_path mounting is disabled because no allowed_host_mount_roots are configured'
)
if not self.allowed_mount_roots:
raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured')
for allowed_root in self.allowed_host_mount_roots:
for allowed_root in self.allowed_mount_roots:
if _is_path_under(host_path, allowed_root):
return
allowed_roots = ', '.join(self.allowed_host_mount_roots)
raise BoxValidationError(f'host_path is outside allowed_host_mount_roots: {allowed_roots}')
allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}')
def _load_profile(self) -> BoxProfile:
profile_name = str(_get_box_config(self.ap).get('profile', 'default')).strip() or 'default'
profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default'
profile = BUILTIN_PROFILES.get(profile_name)
if profile is None:
@@ -592,7 +588,7 @@ class BoxService:
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
)
if self.default_host_workspace:
if self.default_workspace:
guidance += (
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '

View File

@@ -1,4 +1,3 @@
from __future__ import annotations
"""Reusable workspace/session helpers built on top of Box.
This module is the middle layer between the raw Box runtime primitives and
@@ -14,6 +13,8 @@ Higher layers add their own semantics on top, for example:
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
"""
from __future__ import annotations
import os
import textwrap
from typing import Any
@@ -42,9 +43,10 @@ def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str =
if not host_path or not path:
return path
normalized_host = os.path.realpath(host_path)
if path.startswith(normalized_host + '/'):
return mount_path + path[len(normalized_host) :]
if path == normalized_host:
normalized_path = os.path.realpath(path)
if normalized_path.startswith(normalized_host + '/'):
return mount_path + normalized_path[len(normalized_host) :]
if normalized_path == normalized_host:
return mount_path
return path
@@ -86,22 +88,21 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
if not host_path or not command:
return command
normalized_host = os.path.realpath(host_path)
if not command.startswith(normalized_host + '/'):
normalized_command = os.path.realpath(command)
if not normalized_command.startswith(normalized_host + '/'):
return command
rel = command[len(normalized_host) + 1 :]
rel = normalized_command[len(normalized_host) + 1 :]
parts = rel.replace('\\', '/').split('/')
if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'):
return 'python'
return rewrite_mounted_path(command, host_path, mount_path=mount_path)
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return []
return [
filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))
]
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
def classify_python_workspace(host_path: str | None) -> str | None:
@@ -269,6 +270,7 @@ class BoxWorkspaceSession:
cpus: float | None = None,
memory_mb: int | None = None,
pids_limit: int | None = None,
persistent: bool = False,
):
self.box_service = box_service
self.session_id = session_id
@@ -283,6 +285,7 @@ class BoxWorkspaceSession:
self.cpus = cpus
self.memory_mb = memory_mb
self.pids_limit = pids_limit
self.persistent = persistent
def rewrite_path(self, path: str) -> str:
return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path)
@@ -297,6 +300,7 @@ class BoxWorkspaceSession:
'session_id': self.session_id,
'workdir': self.workdir,
'env': self.env,
'persistent': self.persistent,
}
if self.network is not None:
payload['network'] = self.network
@@ -388,17 +392,19 @@ class BoxWorkspaceSession:
command: str,
args: list[str] | None = None,
*,
process_id: str = 'default',
env: dict[str, str] | None = None,
cwd: str = '/workspace',
):
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
payload['process_id'] = process_id
return await self.box_service.start_managed_process(self.session_id, payload)
async def get_managed_process(self):
return await self.box_service.get_managed_process(self.session_id)
async def get_managed_process(self, process_id: str = 'default'):
return await self.box_service.get_managed_process(self.session_id, process_id)
def get_managed_process_websocket_url(self) -> str:
return self.box_service.get_managed_process_websocket_url(self.session_id)
def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
async def cleanup(self) -> None:
await self.box_service.client.delete_session(self.session_id)

View File

@@ -20,7 +20,7 @@ from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig as MCPServerBoxConfig, MCPSessionErrorPhase
class MCPSessionStatus(enum.Enum):
@@ -349,7 +349,7 @@ class RuntimeMCPSession:
return self._box_stdio_runtime.uses_box_stdio()
def _build_box_session_id(self) -> str:
return f'mcp-{self.server_uuid}'
return 'mcp-shared'
def _rewrite_path(self, path: str, host_path: str | None) -> str:
return self._box_stdio_runtime.rewrite_path(path, host_path)

View File

@@ -81,8 +81,14 @@ class BoxStdioSessionRuntime:
cpus=self.config.cpus,
memory_mb=self.config.memory_mb,
pids_limit=self.config.pids_limit,
persistent=True,
)
@property
def process_id(self) -> str:
"""Each MCP server gets a unique process_id within the shared session."""
return self.owner.server_uuid
def uses_box_stdio(self) -> bool:
if self.server_config.get('mode') != 'stdio':
return False
@@ -104,7 +110,9 @@ class BoxStdioSessionRuntime:
if host_path:
install_cmd = self.owner._detect_install_command(host_path)
if install_cmd:
self.ap.logger.info(f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}')
self.ap.logger.info(
f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}'
)
try:
result = await workspace.execute_raw(
install_cmd,
@@ -122,6 +130,7 @@ class BoxStdioSessionRuntime:
await workspace.start_managed_process(
self.server_config['command'],
self.server_config.get('args', []),
process_id=self.process_id,
env=self.server_config.get('env', {}),
)
except Exception:
@@ -129,10 +138,12 @@ class BoxStdioSessionRuntime:
raise
try:
websocket_url = workspace.get_managed_process_websocket_url()
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
self.owner.session = await self.owner.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
raise
@@ -150,7 +161,7 @@ class BoxStdioSessionRuntime:
consecutive_errors = 0
while not self.owner._shutdown_event.is_set():
try:
info = await workspace.get_managed_process()
info = await workspace.get_managed_process(self.process_id)
if isinstance(info, dict):
status = info.get('status', '')
else:
@@ -173,10 +184,13 @@ class BoxStdioSessionRuntime:
if not self.uses_box_stdio():
return
try:
await self._build_workspace().cleanup()
except Exception as exc:
self.ap.logger.warning(f'Failed to cleanup Box session for MCP server {self.server_name}: {exc}')
# In the shared-session model, we do not delete the session itself.
# The managed process exits independently; deleting the session would
# kill other MCP servers sharing the same container.
self.ap.logger.info(
f'MCP server {self.server_name}: process_id={self.process_id} cleanup complete '
f'(shared session {self.owner._build_box_session_id()} kept alive)'
)
def rewrite_path(self, path: str, host_path: str | None) -> str:
return rewrite_mounted_path(path, host_path)

View File

@@ -121,9 +121,7 @@ class NativeToolLoader(loader.ToolLoader):
)
box_service = self.ap.box_service
host_root = (
selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace
)
host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace
if not host_root:
raise ValueError('No host workspace configured for file operations.')

View File

@@ -183,9 +183,9 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
box_service = getattr(self.ap, 'box_service', None)
workspace_root = getattr(box_service, 'default_host_workspace', None)
workspace_root = getattr(box_service, 'default_workspace', None)
if not workspace_root:
raise ValueError('No default host workspace configured for importing skills')
raise ValueError('No default workspace configured for importing skills')
normalized_path = str(sandbox_path).strip() or '/workspace'
if not normalized_path.startswith('/workspace'):

View File

@@ -105,14 +105,22 @@ monitoring:
# Number of expired rows to delete per table batch
delete_batch_size: 1000
box:
profile: 'default'
image: '' # Custom sandbox container image. Leave empty to use the profile default (python:3.11-slim).
runtime_url: '' # Action-RPC WebSocket URL of an external Box Runtime. Leave empty for auto-detection (stdio locally, Docker service in containers).
shared_host_root: './data/box' # For Docker deployment, use '/workspaces'
default_host_workspace: '' # Defaults to '<shared_host_root>/default'
allowed_host_mount_roots: # Defaults to ['<shared_host_root>'] when left empty
- './data/box'
- '/tmp'
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. BOX_BACKEND env var takes precedence.
runtime:
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
local:
profile: 'default'
image: '' # Custom local sandbox image. Leave empty to use the profile default.
host_root: './data/box' # Base host directory for local workspace mounts. For Docker deployment, use '/workspaces'.
default_workspace: '' # Defaults to '<host_root>/default'.
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
- './data/box'
- '/tmp'
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
e2b:
api_key: '' # Can also be set via E2B_API_KEY env var.
api_url: '' # Custom API URL for self-hosted deployments.
template: '' # Default template ID (e.g. 'base', 'python-3.11').
space:
# Space service URL for OAuth and API
url: 'https://space.langbot.app'

View File

@@ -54,6 +54,7 @@
}
],
"knowledge-bases": [],
"box-session-id-template": "{launcher_type}_{launcher_id}",
"rerank-model": "",
"rerank-top-k": 5
},

View File

@@ -124,6 +124,83 @@ stages:
field: __system.is_wizard
operator: neq
value: true
- name: box-session-id-template
label:
en_US: Sandbox Scope
zh_Hans: 沙箱作用域
zh_Hant: 沙箱作用域
ja_JP: サンドボックススコープ
vi_VN: Phạm vi Sandbox
th_TH: ขอบเขต Sandbox
es_ES: Alcance del Sandbox
ru_RU: Область песочницы
description:
en_US: Determines how sandbox environments are shared across messages.
zh_Hans: 决定沙箱环境在不同消息间的共享方式。
zh_Hant: 決定沙箱環境在不同訊息間的共享方式。
ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。
vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.
th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ
es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
ru_RU: Определяет, как песочницы используются совместно между сообщениями.
type: select
required: false
default: "{launcher_type}_{launcher_id}"
options:
- name: "{global}"
label:
en_US: Global (shared by all)
zh_Hans: 全局(所有人共享)
zh_Hant: 全域(所有人共用)
ja_JP: グローバル(全員共有)
vi_VN: Toàn cục (chia sẻ cho tất cả)
th_TH: ทั่วไป (แชร์ทั้งหมด)
es_ES: Global (compartido por todos)
ru_RU: Глобальный (общий для всех)
- name: "{launcher_type}_{launcher_id}"
label:
en_US: Per chat (Recommended)
zh_Hans: 每个会话(推荐)
zh_Hant: 每個會話(推薦)
ja_JP: チャットごと(推奨)
vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị)
th_TH: ต่อแชท (แนะนำ)
es_ES: Por chat (Recomendado)
ru_RU: По чату (Рекомендуется)
- name: "{launcher_type}_{launcher_id}_{sender_id}"
label:
en_US: Per user in chat
zh_Hans: 会话中每个用户
zh_Hant: 會話中每個用戶
ja_JP: チャット内のユーザーごと
vi_VN: Mỗi người dùng trong cuộc trò chuyện
th_TH: ต่อผู้ใช้ในแชท
es_ES: Por usuario en chat
ru_RU: По пользователю в чате
- name: "{launcher_type}_{launcher_id}_{conversation_id}"
label:
en_US: Per conversation context
zh_Hans: 每个对话上下文
zh_Hant: 每個對話上下文
ja_JP: 会話コンテキストごと
vi_VN: Mỗi ngữ cảnh hội thoại
th_TH: ต่อบริบทการสนทนา
es_ES: Por contexto de conversación
ru_RU: По контексту разговора
- name: "{query_id}"
label:
en_US: Per message (isolated)
zh_Hans: 每条消息(完全隔离)
zh_Hant: 每條訊息(完全隔離)
ja_JP: メッセージごと(隔離)
vi_VN: Mỗi tin nhắn (cách ly)
th_TH: ต่อข้อความ (แยกส่วน)
es_ES: Por mensaje (aislado)
ru_RU: По сообщению (изолированно)
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: rerank-model
label:
en_US: Rerank Model

View File

@@ -297,9 +297,14 @@ async def test_full_service_to_remote_runtime(tmp_path):
instance_config=SimpleNamespace(
data={
'box': {
'profile': 'default',
'allowed_host_mount_roots': [str(tmp_path)],
'default_host_workspace': str(host_dir),
'backend': 'local',
'runtime': {'endpoint': ''},
'local': {
'profile': 'default',
'allowed_mount_roots': [str(tmp_path)],
'default_workspace': str(host_dir),
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
}
}
),

View File

@@ -9,16 +9,20 @@ from langbot_plugin.box.client import ActionRPCBoxClient
from langbot.pkg.box.connector import BoxRuntimeConnector
def make_app(logger: Mock, runtime_url: str = ''):
def make_app(logger: Mock, runtime_endpoint: str = ''):
return SimpleNamespace(
logger=logger,
instance_config=SimpleNamespace(
data={
'box': {
'runtime_url': runtime_url,
'profile': 'default',
'allowed_host_mount_roots': [],
'default_host_workspace': '',
'backend': 'local',
'runtime': {'endpoint': runtime_endpoint},
'local': {
'profile': 'default',
'allowed_mount_roots': [],
'default_workspace': '',
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
}
}
),
@@ -26,7 +30,7 @@ def make_app(logger: Mock, runtime_url: str = ''):
def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch):
"""Without runtime_url, on a non-Docker Unix platform, use stdio."""
"""Without runtime.endpoint, on a non-Docker Unix platform, use stdio."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock()))
@@ -36,11 +40,11 @@ def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch
def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch):
"""With an explicit runtime_url, always use WebSocket."""
"""With an explicit runtime.endpoint, always use WebSocket."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
logger = Mock()
connector = BoxRuntimeConnector(make_app(logger, runtime_url='http://box-runtime:5410'))
connector = BoxRuntimeConnector(make_app(logger, runtime_endpoint='http://box-runtime:5410'))
assert connector._uses_websocket() is True
assert isinstance(connector.client, ActionRPCBoxClient)
@@ -76,7 +80,7 @@ def test_box_runtime_connector_ws_relay_url_default(monkeypatch: pytest.MonkeyPa
def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_url='http://box-runtime:5410'))
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
assert connector.ws_relay_base_url == 'http://box-runtime:5410'

View File

@@ -67,12 +67,15 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient):
async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec):
return await self._runtime.start_managed_process(session_id, spec)
async def get_managed_process(self, session_id: str):
return self._runtime.get_managed_process(session_id)
async def get_managed_process(self, session_id: str, process_id: str = 'default'):
return self._runtime.get_managed_process(session_id, process_id)
async def get_session(self, session_id: str):
return self._runtime.get_session(session_id)
async def init(self, config: dict) -> None:
self._runtime.init(config)
class FakeBackend(BaseSandboxBackend):
def __init__(self, logger: Mock, available: bool = True):
@@ -141,19 +144,24 @@ def make_query(query_id: int = 42) -> pipeline_query.Query:
def make_app(
logger: Mock,
allowed_host_mount_roots: list[str] | None = None,
allowed_mount_roots: list[str] | None = None,
profile: str = 'default',
shared_host_root: str = '',
host_root: str = '',
workspace_quota_mb: int | None = None,
):
box_config = {
'profile': profile,
'shared_host_root': shared_host_root,
'allowed_host_mount_roots': allowed_host_mount_roots or [],
'default_host_workspace': '',
'backend': 'local',
'runtime': {'endpoint': ''},
'local': {
'profile': profile,
'host_root': host_root,
'allowed_mount_roots': allowed_mount_roots or [],
'default_workspace': '',
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
}
if workspace_quota_mb is not None:
box_config['workspace_quota_mb'] = workspace_quota_mb
box_config['local']['workspace_quota_mb'] = workspace_quota_mb
return SimpleNamespace(
logger=logger,
@@ -293,14 +301,14 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path):
@pytest.mark.asyncio
async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tmp_path):
async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_path):
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
host_dir = tmp_path / 'default-workspace'
host_dir.mkdir()
app = make_app(logger, [str(tmp_path)])
app.instance_config.data['box']['default_host_workspace'] = str(host_dir)
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
@@ -313,36 +321,36 @@ async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tm
@pytest.mark.asyncio
async def test_box_service_creates_default_host_workspace_on_initialize(tmp_path):
async def test_box_service_creates_default_workspace_on_initialize(tmp_path):
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
allowed_root = tmp_path / 'allowed-root'
allowed_root.mkdir()
default_host_workspace = allowed_root / 'default-workspace'
default_workspace = allowed_root / 'default-workspace'
app = make_app(logger, [str(allowed_root)])
app.instance_config.data['box']['default_host_workspace'] = str(default_host_workspace)
app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
assert default_host_workspace.is_dir()
assert default_workspace.is_dir()
@pytest.mark.asyncio
async def test_box_service_derives_workspace_and_allowed_root_from_shared_host_root(tmp_path):
async def test_box_service_derives_workspace_and_allowed_root_from_host_root(tmp_path):
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
shared_root = tmp_path / 'shared-box-root'
app = make_app(logger, shared_host_root=str(shared_root))
app = make_app(logger, host_root=str(shared_root))
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
assert service.shared_host_root == os.path.realpath(shared_root)
assert service.default_host_workspace == os.path.realpath(shared_root / 'default')
assert service.allowed_host_mount_roots == [os.path.realpath(shared_root)]
assert service.host_root == os.path.realpath(shared_root)
assert service.default_workspace == os.path.realpath(shared_root / 'default')
assert service.allowed_mount_roots == [os.path.realpath(shared_root)]
assert (shared_root / 'default').is_dir()
@@ -557,9 +565,10 @@ async def test_profile_default_provides_defaults():
assert result['ok'] is True
spec = backend.start_specs[0]
profile = BUILTIN_PROFILES['default']
assert spec.network == BoxNetworkMode.OFF
assert spec.image == 'python:3.11-slim'
assert spec.timeout_sec == 30
assert spec.image == profile.image
assert spec.timeout_sec == profile.timeout_sec
@pytest.mark.asyncio
@@ -698,7 +707,7 @@ async def test_box_service_applies_workspace_quota_from_config(tmp_path):
host_dir = tmp_path / 'default-workspace'
host_dir.mkdir()
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=32)
app.instance_config.data['box']['default_host_workspace'] = str(host_dir)
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
@@ -716,7 +725,7 @@ async def test_box_service_rejects_execution_when_workspace_already_exceeds_quot
host_dir.mkdir()
(host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024))
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
app.instance_config.data['box']['default_host_workspace'] = str(host_dir)
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
@@ -735,7 +744,7 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac
host_dir = tmp_path / 'quota-workspace-post'
host_dir.mkdir()
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
app.instance_config.data['box']['default_host_workspace'] = str(host_dir)
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()

View File

@@ -79,6 +79,7 @@ async def test_workspace_session_execute_for_query_uses_session_payload():
'session_id': 'skill-person_123-demo',
'workdir': '/workspace',
'env': {'FOO': 'bar'},
'persistent': False,
'host_path': '/tmp/project',
'host_path_mode': 'rw',
'cmd': 'python run.py',
@@ -111,6 +112,7 @@ async def test_workspace_session_start_managed_process_rewrites_command_and_args
'args': ['/workspace/server.py', '--config', '/workspace/config.json'],
'env': {'TOKEN': '1'},
'cwd': '/workspace',
'process_id': 'default',
}
@@ -133,6 +135,7 @@ def test_workspace_session_build_session_payload_keeps_generic_workspace_shape()
'session_id': 'workspace-1',
'workdir': '/workspace',
'env': {'FOO': 'bar'},
'persistent': False,
'network': 'on',
'read_only_rootfs': False,
'host_path': '/tmp/project',

View File

@@ -528,7 +528,7 @@ class TestGetRuntimeInfoDict:
ap=ap,
)
info = s.get_runtime_info_dict()
assert info['box_session_id'] == 'mcp-test-uuid'
assert info['box_session_id'] == 'mcp-shared'
assert info['box_enabled'] is True
def test_stdio_session_without_box_runtime(self, mcp_module):

View File

@@ -92,12 +92,7 @@ class TestSkillManagerActivation:
'beta': _make_skill_data(name='beta'),
}
response = (
'[ACTIVATE_SKILL: alpha]\n'
'[ACTIVATE_SKILL: beta]\n'
'[ACTIVATE_SKILL: alpha]\n'
'Let me handle this.'
)
response = '[ACTIVATE_SKILL: alpha]\n[ACTIVATE_SKILL: beta]\n[ACTIVATE_SKILL: alpha]\nLet me handle this.'
assert mgr.detect_skill_activations(response) == ['alpha', 'beta']
assert mgr.detect_skill_activation(response) == 'alpha'
@@ -240,7 +235,9 @@ class TestSkillAuthoringToolLoader:
ap = _make_ap()
ap.skill_service = SimpleNamespace(
create_skill=AsyncMock(return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')),
create_skill=AsyncMock(
return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')
),
reload_skills=AsyncMock(),
list_skills=AsyncMock(return_value=[]),
)
@@ -329,7 +326,9 @@ class TestSkillAuthoringToolLoader:
ap = _make_ap()
ap.skill_service = SimpleNamespace(
create_skill=AsyncMock(),
update_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')),
update_skill=AsyncMock(
return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')
),
reload_skills=AsyncMock(),
list_skills=AsyncMock(return_value=[]),
)
@@ -393,7 +392,7 @@ class TestSkillAuthoringToolLoader:
)
ap = _make_ap()
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace')
ap.box_service = SimpleNamespace(default_workspace='/tmp/langbot-workspace')
ap.skill_service = SimpleNamespace(
scan_directory=Mock(
return_value={
@@ -413,7 +412,7 @@ class TestSkillAuthoringToolLoader:
await loader.initialize()
with tempfile.TemporaryDirectory() as tmpdir:
ap.box_service.default_host_workspace = tmpdir
ap.box_service.default_workspace = tmpdir
repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill')
os.makedirs(repo_dir)
@@ -445,7 +444,7 @@ class TestSkillAuthoringToolLoader:
)
ap = _make_ap()
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace')
ap.box_service = SimpleNamespace(default_workspace='/tmp/langbot-workspace')
ap.skill_service = SimpleNamespace(
scan_directory=Mock(),
create_skill=AsyncMock(),
@@ -501,7 +500,7 @@ class TestNativeToolLoaderSkillPaths:
f.write('demo instructions')
ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir)
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
@@ -522,7 +521,7 @@ class TestNativeToolLoaderSkillPaths:
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
default_host_workspace=tmpdir,
default_workspace=tmpdir,
execute_spec_payload=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
@@ -555,7 +554,7 @@ class TestNativeToolLoaderSkillPaths:
with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir)
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)

View File

@@ -110,7 +110,7 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]:
logger = Mock()
box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir)
box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap = SimpleNamespace(box_service=box_service, logger=logger)
return NativeToolLoader(ap), logger

View File

@@ -16,6 +16,7 @@ import {
Download,
PlusIcon,
ChevronLeft,
ChevronRight,
Server,
Github,
BookOpen,
@@ -25,26 +26,20 @@ import {
XCircle,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import {
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
import type {
MCPFormDraft,
MCPFormHandle,
} from '@/app/home/mcp/components/mcp-form/MCPForm';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import type { SkillFormDraft } from '@/app/home/skills/components/skill-form/SkillForm';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
@@ -100,7 +95,6 @@ export default function AddExtensionPage() {
function AddExtensionContent() {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const {
addTask,
@@ -128,11 +122,15 @@ function AddExtensionContent() {
const fileInputRef = useRef<HTMLInputElement>(null);
const mcpFormRef = useRef<MCPFormHandle>(null);
const [mcpTesting, setMcpTesting] = useState(false);
const [mcpDraft, setMcpDraft] = useState<MCPFormDraft | undefined>();
const [skillDraft, setSkillDraft] = useState<SkillFormDraft | undefined>();
// GitHub install state
const [githubURL, setGithubURL] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(null);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
null,
);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
const [githubOwner, setGithubOwner] = useState('');
@@ -141,12 +139,14 @@ function AddExtensionContent() {
const [fetchingAssets, setFetchingAssets] = useState(false);
const [githubInstallStatus, setGithubInstallStatus] =
useState<GithubInstallStatus>(GithubInstallStatus.WAIT_INPUT);
const [githubInstallError, setGithubInstallError] = useState<string | null>(null);
const [githubInstallError, setGithubInstallError] = useState<string | null>(
null,
);
useEffect(() => {
// Clear any stale completed tasks on mount
clearCompletedTasks();
}, []);
}, [clearCompletedTasks]);
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
@@ -161,20 +161,17 @@ function AddExtensionContent() {
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
},
[],
);
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
}, []);
function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
@@ -266,7 +263,7 @@ function AddExtensionContent() {
});
}
},
[t, addTask, setSelectedTaskId, refreshPlugins],
[t, addTask, setSelectedTaskId, refreshPlugins, refreshSkills],
);
const handleFileSelect = useCallback(() => {
@@ -308,13 +305,15 @@ function AddExtensionContent() {
[uploadFile],
);
function handleMCPCreated(serverName: string) {
function handleMCPCreated(_serverName: string) {
setMcpDraft(undefined);
refreshMCPServers();
setPopoverView('menu');
setPopoverOpen(false);
}
function handleSkillCreated(skillName: string) {
function handleSkillCreated(_skillName: string) {
setSkillDraft(undefined);
refreshPlugins();
refreshSkills();
setPopoverView('menu');
@@ -467,13 +466,13 @@ function AddExtensionContent() {
function getPopoverWidth(): string {
switch (popoverView) {
case 'mcp':
return 'w-[500px]';
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
case 'skill':
return 'w-[460px]';
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
case 'github':
return 'w-[460px]';
return 'w-[calc(100vw-2rem)] sm:w-[480px]';
default:
return 'w-[360px]';
return 'w-[calc(100vw-2rem)] sm:w-[380px]';
}
}
@@ -491,9 +490,6 @@ function AddExtensionContent() {
open={popoverOpen}
onOpenChange={(open) => {
setPopoverOpen(open);
if (!open) {
setPopoverView('menu');
}
}}
>
<PopoverTrigger asChild>
@@ -508,12 +504,13 @@ function AddExtensionContent() {
</Button>
</PopoverTrigger>
<PopoverContent
className={`${getPopoverWidth()} p-4 max-h-[80vh] overflow-y-auto`}
forceMount
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
align="end"
>
{/* ===== Menu View ===== */}
{popoverView === 'menu' && (
<div className="space-y-4">
<div className="space-y-4 p-4">
{/* File upload area */}
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
@@ -539,68 +536,75 @@ function AddExtensionContent() {
</p>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-popover px-2 text-muted-foreground">
{t('addExtension.orContinueWith')}
<p className="text-center text-xs text-muted-foreground">
{t('addExtension.orContinueWith')}
</p>
<div className="space-y-2">
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('mcp')}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<Server className="size-4" />
</span>
</div>
</div>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium leading-none">
{t('mcp.addMCPServer')}
</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{t('addExtension.addMCPServerHint')}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
{/* MCP Config button */}
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={() => setPopoverView('mcp')}
>
<Server className="w-4 h-4" />
{t('mcp.addMCPServer')}
</Button>
{/* Two side-by-side buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
className="flex-col h-auto py-3 gap-1"
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('github')}
>
<Github className="w-4 h-4" />
<span className="text-xs">
{t('addExtension.installFromGithub')}
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<Github className="size-4" />
</span>
</Button>
<Button
variant="outline"
className="flex-col h-auto py-3 gap-1"
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium leading-none">
{t('addExtension.installFromGithub')}
</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{t('addExtension.installFromGithubHint')}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('skill')}
>
<BookOpen className="w-4 h-4" />
<span className="text-xs">
{t('addExtension.createSkill')}
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<BookOpen className="size-4" />
</span>
</Button>
</div>
{/* Hints for the two buttons */}
<div className="grid grid-cols-2 gap-2">
<p className="text-[11px] text-muted-foreground text-center px-1">
{t('addExtension.installFromGithubHint')}
</p>
<p className="text-[11px] text-muted-foreground text-center px-1">
{t('addExtension.createSkillHint')}
</p>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium leading-none">
{t('addExtension.createSkill')}
</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{t('addExtension.createSkillHint')}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
</div>
</div>
)}
{/* ===== MCP Form View ===== */}
{popoverView === 'mcp' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button
variant="ghost"
size="icon"
@@ -614,17 +618,19 @@ function AddExtensionContent() {
</h4>
</div>
<div className="max-h-[60vh] overflow-y-auto pr-1">
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<MCPForm
ref={mcpFormRef}
initServerName={undefined}
initialDraft={mcpDraft}
onFormSubmit={() => {}}
onNewServerCreated={handleMCPCreated}
onDraftChange={setMcpDraft}
onTestingChange={setMcpTesting}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t">
<div className="flex items-center justify-end gap-2 bg-popover px-4 pb-4 pt-1">
<Button
type="button"
variant="outline"
@@ -652,8 +658,8 @@ function AddExtensionContent() {
{/* ===== Skill Form View ===== */}
{popoverView === 'skill' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button
variant="ghost"
size="icon"
@@ -667,15 +673,17 @@ function AddExtensionContent() {
</h4>
</div>
<div className="max-h-[60vh] overflow-y-auto pr-1">
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<SkillForm
initSkillName={undefined}
initialDraft={skillDraft}
onNewSkillCreated={handleSkillCreated}
onSkillUpdated={() => {}}
onDraftChange={setSkillDraft}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t">
<div className="flex items-center justify-end gap-2 bg-popover px-4 pb-4 pt-1">
<Button type="submit" form="skill-form" size="sm">
{t('common.save')}
</Button>
@@ -685,8 +693,8 @@ function AddExtensionContent() {
{/* ===== GitHub Install View ===== */}
{popoverView === 'github' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button
variant="ghost"
size="icon"
@@ -703,7 +711,7 @@ function AddExtensionContent() {
</h4>
</div>
<div className="max-h-[60vh] overflow-y-auto pr-1 space-y-3">
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
@@ -743,7 +751,9 @@ function AddExtensionContent() {
size="sm"
className="h-6 text-xs px-2"
onClick={() => {
setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT);
setGithubInstallStatus(
GithubInstallStatus.WAIT_INPUT,
);
setGithubReleases([]);
}}
>
@@ -755,7 +765,7 @@ function AddExtensionContent() {
{githubReleases.map((release) => (
<div
key={release.id}
className="flex items-center justify-between rounded-md border p-2 hover:bg-accent cursor-pointer text-sm"
className="flex cursor-pointer items-center justify-between rounded-md px-2 py-2 text-sm hover:bg-accent"
onClick={() => handleReleaseSelect(release)}
>
<div className="flex-1 min-w-0">
@@ -764,7 +774,9 @@ function AddExtensionContent() {
</div>
<div className="text-[11px] text-muted-foreground">
{release.tag_name} &bull;{' '}
{new Date(release.published_at).toLocaleDateString()}
{new Date(
release.published_at,
).toLocaleDateString()}
</div>
</div>
{release.prerelease && (
@@ -795,7 +807,9 @@ function AddExtensionContent() {
size="sm"
className="h-6 text-xs px-2"
onClick={() => {
setGithubInstallStatus(GithubInstallStatus.SELECT_RELEASE);
setGithubInstallStatus(
GithubInstallStatus.SELECT_RELEASE,
);
setGithubAssets([]);
setSelectedAsset(null);
}}
@@ -805,7 +819,7 @@ function AddExtensionContent() {
</Button>
</div>
{selectedRelease && (
<div className="p-1.5 bg-muted rounded text-[11px]">
<div className="rounded-md bg-muted/40 px-2 py-1.5 text-[11px]">
<span className="font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</span>
@@ -815,7 +829,7 @@ function AddExtensionContent() {
{githubAssets.map((asset) => (
<div
key={asset.id}
className="flex items-center justify-between rounded-md border p-2 hover:bg-accent cursor-pointer"
className="flex cursor-pointer items-center justify-between rounded-md px-2 py-2 hover:bg-accent"
onClick={() => handleAssetSelect(asset)}
>
<span className="text-xs truncate">{asset.name}</span>
@@ -839,7 +853,9 @@ function AddExtensionContent() {
size="sm"
className="h-6 text-xs px-2"
onClick={() => {
setGithubInstallStatus(GithubInstallStatus.SELECT_ASSET);
setGithubInstallStatus(
GithubInstallStatus.SELECT_ASSET,
);
setSelectedAsset(null);
}}
>
@@ -848,10 +864,12 @@ function AddExtensionContent() {
</Button>
</div>
{selectedRelease && selectedAsset && (
<div className="p-2 bg-muted rounded space-y-1 text-xs">
<div className="space-y-1 rounded-md bg-muted/40 px-2 py-2 text-xs">
<div>
<span className="font-medium">Repository: </span>
<span>{githubOwner}/{githubRepo}</span>
<span>
{githubOwner}/{githubRepo}
</span>
</div>
<div>
<span className="font-medium">Release: </span>
@@ -863,10 +881,7 @@ function AddExtensionContent() {
</div>
</div>
)}
<Button
className="w-full"
onClick={handleGithubConfirm}
>
<Button className="w-full" onClick={handleGithubConfirm}>
{t('common.confirm')}
</Button>
</div>

View File

@@ -11,13 +11,6 @@ import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Form,
FormControl,
@@ -94,18 +87,16 @@ function StatusDisplay({
// Tools list component
function ToolsList({ tools }: { tools: MCPTool[] }) {
return (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
<div className="max-h-[300px] space-y-1 overflow-y-auto">
{tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
{tool.description && (
<CardDescription className="text-xs">
{tool.description}
</CardDescription>
)}
</CardHeader>
</Card>
<div key={index} className="rounded-md px-1 py-2">
<div className="text-sm font-medium">{tool.name}</div>
{tool.description && (
<div className="mt-0.5 text-xs text-muted-foreground">
{tool.description}
</div>
)}
</div>
))}
</div>
);
@@ -164,10 +155,14 @@ type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
ssereadtimeout: number;
};
export type MCPFormDraft = Partial<FormValues>;
interface MCPFormProps {
initServerName?: string;
initialDraft?: MCPFormDraft;
onFormSubmit: () => void;
onNewServerCreated: (serverName: string) => void;
onDraftChange?: (draft: MCPFormDraft) => void;
onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void;
}
@@ -181,8 +176,10 @@ export interface MCPFormHandle {
const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
{
initServerName,
initialDraft,
onFormSubmit,
onNewServerCreated,
onDraftChange,
onDirtyChange,
onTestingChange,
},
@@ -191,6 +188,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const isEditMode = !!initServerName;
const initialDraftRef = useRef(initialDraft);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
@@ -203,6 +201,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
...initialDraftRef.current,
},
});
@@ -259,9 +258,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
...initialDraftRef.current,
});
setExtraArgs([]);
setStdioArgs([]);
setExtraArgs(initialDraftRef.current?.extra_args ?? []);
setStdioArgs(initialDraftRef.current?.args ?? []);
setRuntimeInfo(null);
isInitializing.current = false;
}
@@ -274,6 +274,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
};
}, [initServerName]);
useEffect(() => {
if (!onDraftChange || isEditMode) return;
const subscription = form.watch((values) => {
onDraftChange({
...values,
extra_args: extraArgs,
args: stdioArgs,
} as MCPFormDraft);
});
return () => subscription.unsubscribe();
}, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]);
// Poll for updates when runtime_info status is CONNECTING
useEffect(() => {
if (
@@ -595,126 +609,136 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<form
id="mcp-form"
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-6"
className="space-y-5"
>
{/* Runtime info: status + tools (edit mode only) */}
{isEditMode && runtimeInfo && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t('mcp.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
<section className="space-y-3">
<h3 className="text-sm font-medium">{t('mcp.title')}</h3>
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="rounded-md bg-muted/40 p-3">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<>
<div className="text-sm font-medium">
{t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0,
})}
</div>
<ToolsList tools={runtimeInfo.tools} />
</>
)}
</CardContent>
</Card>
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<>
<div className="text-sm font-medium">
{t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0,
})}
</div>
<ToolsList tools={runtimeInfo.tools} />
</>
)}
</section>
)}
{/* Server configuration */}
<Card>
<CardHeader>
<CardTitle>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
</CardTitle>
<CardDescription>
{t('mcp.extraParametersDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.name')}
<span className="text-destructive">*</span>
</FormLabel>
<section className="space-y-4">
{isEditMode && (
<h3 className="text-sm font-medium">{t('mcp.editServer')}</h3>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.serverMode')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<Input {...field} disabled={isEditMode} />
<SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.serverMode')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
{(watchMode === 'sse' || watchMode === 'http') && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.url')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
<Input {...field} />
</FormControl>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
{(watchMode === 'sse' || watchMode === 'http') && (
<>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'sse' && (
<FormField
control={form.control}
name="url"
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.url')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
@@ -725,133 +749,104 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</FormItem>
)}
/>
)}
</>
)}
{watchMode === 'sse' && (
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'stdio' && (
<>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.command')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</>
)}
/>
{watchMode === 'stdio' && (
<>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.command')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('mcp.args')}</FormLabel>
<div className="space-y-2">
{stdioArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('mcp.args')}
value={arg.value}
onChange={(e) => updateStdioArg(index, e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeStdioArg(index)}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addStdioArg}>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem>
</>
)}
<FormItem>
<FormLabel>{t('mcp.args')}</FormLabel>
<div className="space-y-2">
{stdioArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('mcp.args')}
value={arg.value}
onChange={(e) =>
updateStdioArg(index, e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeStdioArg(index)}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addStdioArg}
>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem>
</>
)}
<FormItem>
<FormLabel>
<FormItem>
<FormLabel>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers')
: t('mcp.env')}
</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeExtraArg(index)}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers')
: t('mcp.env')}
</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeExtraArg(index)}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</CardContent>
</Card>
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</section>
</form>
</Form>
);

View File

@@ -143,7 +143,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
</div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogContent className="max-h-[min(420px,80vh)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -12,52 +12,72 @@ import { toast } from 'sonner';
interface SkillFormProps {
initSkillName?: string;
initialDraft?: SkillFormDraft;
onNewSkillCreated: (skillName: string) => void;
onSkillUpdated: (skillName: string) => void;
onDraftChange?: (draft: SkillFormDraft) => void;
}
export default function SkillForm({
initSkillName,
onNewSkillCreated,
onSkillUpdated,
}: SkillFormProps) {
const { t } = useTranslation();
const [skill, setSkill] = useState<Partial<Skill>>({
export interface SkillFormDraft {
skill: Partial<Skill>;
showAdvanced: boolean;
}
const emptySkillDraft: SkillFormDraft = {
skill: {
name: '',
display_name: '',
description: '',
instructions: '',
package_root: '',
auto_activate: true,
});
},
showAdvanced: false,
};
export default function SkillForm({
initSkillName,
initialDraft,
onNewSkillCreated,
onSkillUpdated,
onDraftChange,
}: SkillFormProps) {
const { t } = useTranslation();
const initialDraftRef = useRef(initialDraft ?? emptySkillDraft);
const [skill, setSkill] = useState<Partial<Skill>>(
initialDraftRef.current.skill,
);
const [scanning, setScanning] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(
initialDraftRef.current.showAdvanced,
);
const loadSkill = useCallback(
async (skillName: string) => {
try {
const resp = await httpClient.getSkill(skillName);
setSkill(resp.skill);
} catch (error) {
console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error));
}
},
[t],
);
useEffect(() => {
if (initSkillName) {
loadSkill(initSkillName);
return;
}
setSkill({
name: '',
display_name: '',
description: '',
instructions: '',
package_root: '',
auto_activate: true,
});
setShowAdvanced(false);
}, [initSkillName]);
setSkill(initialDraftRef.current.skill);
setShowAdvanced(initialDraftRef.current.showAdvanced);
}, [initSkillName, loadSkill]);
async function loadSkill(skillName: string) {
try {
const resp = await httpClient.getSkill(skillName);
setSkill(resp.skill);
} catch (error) {
console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error));
}
}
useEffect(() => {
if (initSkillName) return;
onDraftChange?.({ skill, showAdvanced });
}, [initSkillName, onDraftChange, skill, showAdvanced]);
async function scanDirectory() {
const path = skill.package_root?.trim();
@@ -183,10 +203,16 @@ export default function SkillForm({
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="auto_activate">{t('skills.autoActivate')}</Label>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="auto_activate">{t('skills.autoActivate')}</Label>
<p className="text-xs leading-relaxed text-muted-foreground">
{t('skills.autoActivateDescription')}
</p>
</div>
<Switch
id="auto_activate"
className="mt-0.5"
checked={skill.auto_activate ?? true}
onCheckedChange={(checked) =>
setSkill({ ...skill, auto_activate: checked })
@@ -194,10 +220,10 @@ export default function SkillForm({
/>
</div>
<div className="border rounded-md">
<div className="space-y-3">
<button
type="button"
className="flex items-center justify-between w-full p-3 text-sm font-medium text-left"
className="flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left text-sm font-medium hover:bg-muted/70"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{t('skills.advancedSettings')}
@@ -208,7 +234,7 @@ export default function SkillForm({
)}
</button>
{showAdvanced && (
<div className="p-3 pt-0 space-y-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('skills.packageRoot')}</Label>
<div className="flex gap-2">

View File

@@ -1347,6 +1347,8 @@ const enUS = {
skillDescription: 'Skill Description',
skillInstructions: 'Instructions',
autoActivate: 'Auto Activate',
autoActivateDescription:
'When enabled, the Agent may match and activate this skill based on its description during conversations.',
saveSuccess: 'Saved successfully',
saveError: 'Save failed: ',
createSuccess: 'Created successfully',
@@ -1475,6 +1477,7 @@ const enUS = {
uploadExtension: 'Drag & drop or click to upload',
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
orContinueWith: 'or choose an action below',
addMCPServerHint: 'Connect an MCP tool server extension',
installFromGithub: 'Install Plugin from GitHub',
installFromGithubHint: 'Install plugin extension from GitHub Release',
createSkill: 'Create New Skill',

View File

@@ -1392,6 +1392,7 @@ const jaJP = {
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
uploadHint: '.zipスキルと.lbpkgプラグインファイルに対応',
orContinueWith: 'または以下の操作を選択',
addMCPServerHint: 'MCPツールサーバー拡張を接続',
installFromGithub: 'GitHubからプラグインをインストール',
installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール',
createSkill: '新しいスキルを作成',

View File

@@ -1291,6 +1291,8 @@ const zhHans = {
skillDescription: '技能描述',
skillInstructions: '指令内容',
autoActivate: '自动激活',
autoActivateDescription:
'开启后Agent 会在对话中根据技能描述自动匹配并激活此技能。',
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功',
@@ -1414,6 +1416,7 @@ const zhHans = {
uploadExtension: '拖拽或点击上传扩展包',
uploadHint: '支持 .zip技能和 .lbpkg插件文件',
orContinueWith: '或选择以下操作',
addMCPServerHint: '连接一个 MCP 工具服务器扩展',
installFromGithub: '从 GitHub 安装插件',
installFromGithubHint: '从 GitHub Release 安装插件扩展',
createSkill: '创建新的技能',

View File

@@ -1327,6 +1327,7 @@ const zhHant = {
uploadExtension: '拖拽或點擊上傳擴充套件',
uploadHint: '支援 .zip技能和 .lbpkg插件檔案',
orContinueWith: '或選擇以下操作',
addMCPServerHint: '連接一個 MCP 工具伺服器擴充',
installFromGithub: '從 GitHub 安裝插件',
installFromGithubHint: '從 GitHub Release 安裝插件擴充',
createSkill: '建立新的技能',
@@ -1360,6 +1361,8 @@ const zhHant = {
skillDescription: '技能描述',
skillInstructions: '指令內容',
autoActivate: '自動啟用',
autoActivateDescription:
'開啟後Agent 會在對話中根據技能描述自動匹配並啟用此技能。',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '創建成功',