mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
fix(box): restore sandbox config and shared mcp runtime
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
}
|
||||
],
|
||||
"knowledge-bases": [],
|
||||
"box-session-id-template": "{launcher_type}_{launcher_id}",
|
||||
"rerank-model": "",
|
||||
"rerank-top-k": 5
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': ''},
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} •{' '}
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1392,6 +1392,7 @@ const jaJP = {
|
||||
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
|
||||
uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応',
|
||||
orContinueWith: 'または以下の操作を選択',
|
||||
addMCPServerHint: 'MCPツールサーバー拡張を接続',
|
||||
installFromGithub: 'GitHubからプラグインをインストール',
|
||||
installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール',
|
||||
createSkill: '新しいスキルを作成',
|
||||
|
||||
@@ -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: '创建新的技能',
|
||||
|
||||
@@ -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: '創建成功',
|
||||
|
||||
Reference in New Issue
Block a user