From b6da2e5539714be6b718a029cf93804c95225713 Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:51:25 +0800 Subject: [PATCH 1/4] feat(studio): add Codex sandbox environments --- frontend/README.md | 19 +- frontend/server/environments/dockerfile.py | 12 +- frontend/server/environments/models.py | 9 +- frontend/server/environments/service.py | 63 +- .../server/environments/session_mounts.py | 5 +- .../server/environments/tool_provisioning.py | 109 ++- frontend/server/studio_tools/__init__.py | 10 + frontend/server/studio_tools/codex_sandbox.py | 763 ++++++++++++++++ frontend/server/studio_tools/connector.py | 12 +- frontend/server/studio_tools/registry.py | 26 +- frontend/server/studio_tools/sandbox_shell.py | 65 +- frontend/src/App.tsx | 99 +- frontend/src/adk/client.ts | 42 +- frontend/src/blocks.ts | 120 ++- frontend/src/styles.css | 114 +++ frontend/src/ui/AgentTopology.tsx | 6 + frontend/src/ui/Blocks.tsx | 76 +- frontend/src/ui/CodeEditor.tsx | 25 +- frontend/src/ui/EnvironmentCenter.css | 621 ++++++------- frontend/src/ui/EnvironmentCenter.tsx | 853 +++++++++--------- frontend/src/ui/ResourceCollection.tsx | 2 +- frontend/src/ui/SessionEnvironmentPicker.tsx | 17 +- frontend/src/ui/StudioPackageOption.css | 28 + frontend/src/ui/StudioPackageOption.tsx | 24 +- .../ui/builtin-tools/codexSandboxProgress.ts | 418 +++++++++ frontend/src/ui/builtin-tools/registry.ts | 8 + .../src/ui/environmentDockerfileUpload.ts | 29 + frontend/src/ui/environmentModel.ts | 72 +- frontend/tests/builtinToolStatus.test.mjs | 6 +- frontend/tests/codexSandboxProgress.test.mjs | 658 ++++++++++++++ frontend/tests/environmentCenter.test.mjs | 204 ++++- frontend/tests/newChatComposerLayout.test.mjs | 2 +- frontend/tests/resourceCollection.test.mjs | 2 +- .../tests/sessionEnvironmentMount.test.mjs | 34 +- tests/cli/test_frontend_invocation.py | 28 + tests/cli/test_frontend_runtime_proxy.py | 29 +- .../server/environments/test_environments.py | 206 ++++- .../environments/test_session_mounts.py | 52 +- .../environments/test_tool_provisioning.py | 70 ++ .../server/studio_tools/test_codex_sandbox.py | 630 +++++++++++++ .../server/studio_tools/test_connector.py | 80 +- .../server/studio_tools/test_sandbox_shell.py | 188 +++- .../agentkit/test_studio_channel.py | 80 +- veadk/cli/cli_frontend.py | 61 +- veadk/cli/frontend_invocation.py | 30 +- .../agentkit/studio_channel/protocol.py | 2 +- .../agentkit/studio_channel/routes.py | 17 +- .../agentkit/studio_channel/tool.py | 3 +- 48 files changed, 5066 insertions(+), 963 deletions(-) create mode 100644 frontend/server/studio_tools/codex_sandbox.py create mode 100644 frontend/src/ui/builtin-tools/codexSandboxProgress.ts create mode 100644 frontend/tests/codexSandboxProgress.test.mjs create mode 100644 tests/frontend/server/studio_tools/test_codex_sandbox.py diff --git a/frontend/README.md b/frontend/README.md index 61f6a313f..3815af74f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -509,14 +509,25 @@ The Studio `环境` page stores each environment definition, generated Dockerfil build version, log metadata, and resulting image reference in the private Studio TOS bucket. Creating or saving an environment starts an asynchronous CodePipeline build and pushes the resulting image to Container Registry. -New environments default to the AIO Sandbox base image, which keeps the inherited -`/opt/gem/run.sh` entrypoint and port `8080` so the Sandbox Shell API remains -available. Standard Ubuntu 22.04 and 24.04 images remain supported, and records -created before the base-environment field was introduced resolve to Ubuntu. +Custom and Dockerfile environments can select a preset environment: none, AIO +Sandbox, or Codex Sandbox. Selecting a preset pins its `FROM` instruction ahead +of the editable Dockerfile body; selecting none leaves the complete Dockerfile +under user control. AIO Sandbox keeps the inherited `/opt/gem/run.sh` entrypoint +and port `8080`, while Codex Sandbox exposes task delegation through its Codex +App Server instead of the generic Sandbox shell tool. Legacy records created +before preset environments were introduced remain compatible. Each image version exposes a read-only Manifest at `/web/environments/{environmentId}/builds/{versionId}/manifest`; the Studio environment card opens the same version-bound contract as YAML for inspection and copying. + +When an environment is mounted to an Agent conversation, Studio assigns a new +`mount_instance_id`. Sandbox Tool Sessions are reused only while the Agent +session, mount instance, environment version, Tool ID, image, provider, and +region all remain unchanged. Unmounting and mounting again creates a new mount +instance and therefore a new Sandbox Tool Session. Codex Sandbox progress and +its Sandbox Session and Codex Thread identifiers are streamed into the normal +tool-call card and preserved in conversation history. Volcengine builds use the Aliyun PyPI mirror, Huawei Cloud Python source mirror, and npmmirror for Playwright browsers; BytePlus builds use the corresponding official sources. Cross-version Python combinations are compiled from pinned diff --git a/frontend/server/environments/dockerfile.py b/frontend/server/environments/dockerfile.py index 0a5bdf2c2..2afac8da3 100644 --- a/frontend/server/environments/dockerfile.py +++ b/frontend/server/environments/dockerfile.py @@ -17,6 +17,7 @@ from __future__ import annotations from dataclasses import dataclass +import re from .models import EnvironmentInput @@ -201,6 +202,8 @@ def build_dockerfile(config: EnvironmentInput) -> str: """Build the canonical Dockerfile when the user did not provide one.""" if config.dockerfile: return validate_dockerfile(config.dockerfile) + if config.base_environment == "codex-sandbox": + raise ValueError("Codex Sandbox 预制环境必须提供包含基础镜像的 Dockerfile。") os_label, ubuntu_base_image = _OPERATING_SYSTEMS[config.operating_system] uses_aio_python = config.base_environment == "aio-sandbox" @@ -350,11 +353,18 @@ def build_dockerfile(config: EnvironmentInput) -> str: def environment_base_image(config: EnvironmentInput) -> str: if config.base_environment == "aio-sandbox": return AIO_BASE_IMAGE + if config.base_environment == "codex-sandbox": + match = re.search( + r"^\s*FROM(?:\s+--platform=\S+)?\s+(\S+)", + config.dockerfile, + flags=re.IGNORECASE | re.MULTILINE, + ) + return match.group(1) if match else "" return _OPERATING_SYSTEMS[config.operating_system][1] def environment_capabilities(config: EnvironmentInput) -> list[str]: - if config.base_environment == "aio-sandbox": + if config.base_environment in {"aio-sandbox", "codex-sandbox"}: return ["shell-exec"] return [] diff --git a/frontend/server/environments/models.py b/frontend/server/environments/models.py index 0b149d6e7..b769de56d 100644 --- a/frontend/server/environments/models.py +++ b/frontend/server/environments/models.py @@ -24,7 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator EnvironmentOperatingSystem = Literal["ubuntu-22.04", "ubuntu-24.04"] -EnvironmentBaseEnvironment = Literal["ubuntu", "aio-sandbox"] +EnvironmentBaseEnvironment = Literal["ubuntu", "aio-sandbox", "codex-sandbox"] EnvironmentLanguage = Literal["python-3.10", "python-3.12"] EnvironmentBuildStatus = Literal[ "preparing", @@ -250,11 +250,16 @@ def normalize(self) -> EnvironmentInput: self.description = self.description.strip() self.dockerfile = self.dockerfile.strip() if ( + "base_environment" not in self.model_fields_set + and "/codexenv:" in self.dockerfile.lower() + ): + self.base_environment = "codex-sandbox" + elif ( "base_environment" not in self.model_fields_set and "aio.sandbox" in self.dockerfile.lower() ): self.base_environment = "aio-sandbox" - if self.base_environment == "aio-sandbox": + if self.base_environment in {"aio-sandbox", "codex-sandbox"}: self.operating_system = "ubuntu-22.04" self.language = "python-3.12" if not self.name: diff --git a/frontend/server/environments/service.py b/frontend/server/environments/service.py index cdc8b910c..71f062b02 100644 --- a/frontend/server/environments/service.py +++ b/frontend/server/environments/service.py @@ -684,7 +684,7 @@ async def resolve_for_agent( environment_id, resolved_version, ) - if version_config.base_environment == "aio-sandbox" and ( + if version_config.base_environment in {"aio-sandbox", "codex-sandbox"} and ( not build.tool_id or build.tool_status != "ready" ): build = await self._begin_aio_tool_provisioning(repository, owner_id, build) @@ -713,7 +713,7 @@ async def _begin_aio_tool_provisioning( build.environment_id, build.version_id, ) - if environment.base_environment != "aio-sandbox": + if environment.base_environment not in {"aio-sandbox", "codex-sandbox"}: return build if build.tool_id and build.tool_status == "ready": return build @@ -775,9 +775,34 @@ async def _complete_tool_provisioning( image=build.image, provider=resources.provider, region=resources.region, + existing_tool_id=build.tool_id, + on_created=lambda state: self._persist_creating_tool( + repository, + owner_id, + build, + state.tool_id, + ), ) except asyncio.CancelledError: raise + except TimeoutError as error: + current = await repository.get_build( + owner_id, build.environment_id, build.version_id + ) + if current.tool_id and current.tool_status == "ready": + return + waiting = current.model_copy( + update={ + "status": "building", + "tool_status": "creating", + "progress_error": str(error).strip() or type(error).__name__, + "current_step": "AgentKit Sandbox Tool 仍在准备", + "steps": _with_tool_step(current.steps, "running"), + "updated_at": _now(), + } + ) + await repository.update_build(owner_id, waiting) + return except Exception as error: # noqa: BLE001 - persist provisioning failure current = await repository.get_build( owner_id, build.environment_id, build.version_id @@ -807,6 +832,7 @@ async def _complete_tool_provisioning( "tool_id": tool.tool_id, "tool_status": tool.status, "error": "", + "progress_error": "", "current_step": "环境与 Sandbox Tool 已就绪", "steps": _with_tool_step(current.steps, "succeeded"), "updated_at": _now(), @@ -814,6 +840,39 @@ async def _complete_tool_provisioning( ) await repository.update_build(owner_id, ready) + async def _persist_creating_tool( + self, + repository: TosEnvironmentRepository, + owner_id: str, + build: EnvironmentBuild, + tool_id: str, + ) -> None: + current = await repository.get_build( + owner_id, build.environment_id, build.version_id + ) + if current.tool_id and current.tool_status == "ready": + return + if current.tool_id and current.tool_id != tool_id: + raise RuntimeError("环境构建记录关联了不同的 Sandbox Tool。") + if ( + current.tool_id == tool_id + and current.tool_status == "creating" + and not current.error + and not current.progress_error + ): + return + creating = current.model_copy( + update={ + "status": "building", + "tool_id": tool_id, + "tool_status": "creating", + "error": "", + "progress_error": "", + "updated_at": _now(), + } + ) + await repository.update_build(owner_id, creating) + async def get_skill_files_for_agent( self, owner_id: str, diff --git a/frontend/server/environments/session_mounts.py b/frontend/server/environments/session_mounts.py index 3c8698a84..522d13b79 100644 --- a/frontend/server/environments/session_mounts.py +++ b/frontend/server/environments/session_mounts.py @@ -33,6 +33,7 @@ class SessionEnvironmentSelection(BaseModel): environment_id: str = Field(min_length=32, max_length=32) environment_version_id: str = Field(default="", max_length=128) + mount_instance_id: str = Field(default="", max_length=128) class SessionEnvironmentSelections(RootModel[list[SessionEnvironmentSelection]]): @@ -62,6 +63,7 @@ class SessionEnvironmentMount: manifest: Mapping[str, Any] = field(default_factory=dict) tool_id: str = "" tool_status: str = "" + mount_instance_id: str = "" class _StudioToolContext(Protocol): @@ -104,7 +106,7 @@ async def resolve( spec = manifest.get("spec") if not isinstance(spec, dict): raise TypeError("环境 Manifest 缺少 spec。") - if spec.get("baseEnvironment") != "aio-sandbox": + if spec.get("baseEnvironment") not in {"aio-sandbox", "codex-sandbox"}: raise ValueError("所选环境不支持 Sandbox 命令执行。") image = _string_value(spec.get("image")) if not image: @@ -135,6 +137,7 @@ async def resolve( manifest=manifest, tool_id=tool_id, tool_status=tool_status, + mount_instance_id=selection.mount_instance_id.strip(), ) async def resolve_many( diff --git a/frontend/server/environments/tool_provisioning.py b/frontend/server/environments/tool_provisioning.py index 1b33e5c5c..f79c16205 100644 --- a/frontend/server/environments/tool_provisioning.py +++ b/frontend/server/environments/tool_provisioning.py @@ -20,7 +20,7 @@ import hashlib import secrets import time -from collections.abc import Callable, Mapping +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any, Protocol @@ -143,6 +143,8 @@ async def ensure_ready( image: str, provider: str, region: str, + existing_tool_id: str = "", + on_created: Callable[[EnvironmentToolState], Awaitable[None]] | None = None, ) -> EnvironmentToolState: ... @@ -172,42 +174,80 @@ async def ensure_ready( image: str, provider: str, region: str, + existing_tool_id: str = "", + on_created: Callable[[EnvironmentToolState], Awaitable[None]] | None = None, ) -> EnvironmentToolState: normalized_image = image.strip() if not normalized_image: - raise ValueError("AIO environment image must not be empty.") + raise ValueError("Sandbox environment image must not be empty.") key = (provider.strip(), region.strip(), normalized_image) lock = self._locks.setdefault(key, asyncio.Lock()) async with lock: - client = self._client_factory(key[0], key[1]) - tool_envs = dict(_PRIVATE_TOOL_ENVS) - if self._model_environment_resolver is not None: - tool_envs.update( - { - env_key: str(value).strip() - for env_key, value in self._model_environment_resolver( - key[0], key[1] - ).items() - if env_key in _MODEL_TOOL_ENV_KEYS and str(value).strip() - } - ) + loop = asyncio.get_running_loop() + + async def invoke_created(state: EnvironmentToolState) -> None: + if on_created is not None: + await on_created(state) + + def notify_created(state: EnvironmentToolState) -> None: + if on_created is None: + return + future = asyncio.run_coroutine_threadsafe(invoke_created(state), loop) + future.result() + return await asyncio.to_thread( - self._ensure_ready, - client, + self._ensure_ready_for_location, + key[0], + key[1], normalized_image, - tool_envs, + existing_tool_id.strip(), + notify_created, + ) + + def _ensure_ready_for_location( + self, + provider: str, + region: str, + image: str, + existing_tool_id: str, + on_created: Callable[[EnvironmentToolState], None], + ) -> EnvironmentToolState: + client = self._client_factory(provider, region) + tool_envs = dict(_PRIVATE_TOOL_ENVS) + if self._model_environment_resolver is not None: + tool_envs.update( + { + env_key: str(value).strip() + for env_key, value in self._model_environment_resolver( + provider, region + ).items() + if env_key in _MODEL_TOOL_ENV_KEYS and str(value).strip() + } ) + return self._ensure_ready( + client, + image, + tool_envs, + existing_tool_id, + on_created, + ) def _ensure_ready( self, client: Any, image: str, tool_envs: Mapping[str, str], + existing_tool_id: str, + on_created: Callable[[EnvironmentToolState], None], ) -> EnvironmentToolState: from agentkit.sdk.tools import types as tools_types name = environment_tool_name(image) - match = _find_tool(client, tools_types, name) + match = ( + client.get_tool(tools_types.GetToolRequest(ToolId=existing_tool_id)) + if existing_tool_id + else _find_tool(client, tools_types, name) + ) created_new = match is None if match is None: try: @@ -251,9 +291,21 @@ def _ensure_ready( tool_id = _validated_tool_id(match, image) if not tool_id: raise RuntimeError("AgentKit did not return a Tool ID.") + current_status = "creating" if not created_new: - current = client.get_tool(tools_types.GetToolRequest(ToolId=tool_id)) - if _tool_requires_update(current, image, tool_envs): + current = ( + match + if existing_tool_id + else client.get_tool(tools_types.GetToolRequest(ToolId=tool_id)) + ) + current_status = _tool_status(current) + # An update restarts AgentKit's image preparation. A process restart + # can observe the Tool while the original create/update is still in + # progress, so never submit another update until that operation has + # reached a terminal state. + if current_status == _READY_STATUS and _tool_requires_update( + current, image, tool_envs + ): current_envs = { str(getattr(item, "key", "") or ""): str( getattr(item, "value", "") or "" @@ -278,11 +330,22 @@ def _ensure_ready( ], ) ) + current_status = "creating" + + on_created( + EnvironmentToolState( + tool_id=tool_id, + name=name, + status=( + _READY_STATUS if current_status == _READY_STATUS else "creating" + ), + ) + ) deadline = time.monotonic() + self._timeout_seconds while True: tool = client.get_tool(tools_types.GetToolRequest(ToolId=tool_id)) - status = str(getattr(tool, "status", "") or "").strip().lower() + status = _tool_status(tool) if status == _READY_STATUS: return EnvironmentToolState( tool_id=tool_id, @@ -312,6 +375,10 @@ def _tool_id(tool: Any) -> str: return str(getattr(tool, "tool_id", "") or "").strip() +def _tool_status(tool: Any) -> str: + return str(getattr(tool, "status", "") or "").strip().lower() + + def _tool_requires_update( tool: Any, image: str, diff --git a/frontend/server/studio_tools/__init__.py b/frontend/server/studio_tools/__init__.py index 93fc23bcd..1dfd40404 100644 --- a/frontend/server/studio_tools/__init__.py +++ b/frontend/server/studio_tools/__init__.py @@ -14,6 +14,11 @@ """Studio BFF-owned dynamic tools and the Runtime WebSocket bridge.""" +from frontend.server.studio_tools.codex_sandbox import ( + CodexSandboxConnection, + CodexSandboxDelegate, + register_codex_sandbox_tool, +) from frontend.server.studio_tools.connector import ( StudioChannelError, StudioToolRun, @@ -25,6 +30,7 @@ StudioToolCatalogSnapshot, StudioToolExecutionContext, StudioToolRegistry, + StudioToolRuntimeError, build_studio_tool_registry, ) from frontend.server.studio_tools.sandbox_shell import ( @@ -37,6 +43,8 @@ __all__ = [ "AgentkitEnvironmentSandboxResolver", + "CodexSandboxConnection", + "CodexSandboxDelegate", "SandboxExecutionTarget", "SandboxTargetResolver", "StudioChannelError", @@ -45,9 +53,11 @@ "StudioToolExecutionContext", "StudioToolRegistry", "StudioToolRun", + "StudioToolRuntimeError", "build_studio_tool_registry", "execute_in_sandbox", "open_studio_tool_run", + "register_codex_sandbox_tool", "register_sandbox_shell_tool", "runtime_supports_bff_tools", ] diff --git a/frontend/server/studio_tools/codex_sandbox.py b/frontend/server/studio_tools/codex_sandbox.py new file mode 100644 index 000000000..2a39c43ae --- /dev/null +++ b/frontend/server/studio_tools/codex_sandbox.py @@ -0,0 +1,763 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Delegate a Studio tool call to Codex in a mounted CodeEnv Sandbox.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx + +from frontend.server.environments.session_mounts import ( + SessionEnvironmentMount, + SessionEnvironmentMountRegistry, +) +from frontend.server.studio_tools.registry import ( + StudioTool, + StudioToolExecutionContext, + StudioToolExecutionError, + StudioToolRegistry, + StudioToolRuntimeError, +) +from frontend.server.studio_tools.sandbox_shell import ( + SandboxExecutionTarget, + SandboxResolutionError, + SandboxTargetResolver, +) +from veadk.cli.codex_app_server import ( + CodexAppServerError, + CodexAppServerEvent, + CodexAppServerSession, + CodexAppServerTransportError, + CodexAppServerTurnTimeoutError, + CodexPermissionSettings, + sandbox_service_url, +) +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + +_CODEX_PERMISSIONS = CodexPermissionSettings( + approval_policy="never", + approvals_reviewer="auto_review", + sandbox_mode="danger-full-access", + network_access=True, +) +_CONNECT_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0) +_READINESS_TIMEOUT_SECONDS = 5.0 +_MAX_RESULT_CHARACTERS = 100_000 +_MAX_PROGRESS_BYTES = 64 * 1024 +_MAX_PROGRESS_STRING_CHARACTERS = 16_000 +_MAX_PROGRESS_COLLECTION_ITEMS = 100 +_MAX_ACTIVITY_BYTES = 128 * 1024 +_MAX_ACTIVITY_EVENTS = 100 +_SENSITIVE_KEY_RE = re.compile( + r"(?i)(?:api[_-]?key|access[_-]?key|secret|token|authorization|password)" +) +_SENSITIVE_VALUE_RE = re.compile( + r"(?i)((?:api[_-]?key|access[_-]?key|secret|token|authorization|password)" + r"\s*[:=]\s*)(?:[\"'][^\"']*[\"']|[^\s,;]+)" +) +_BEARER_RE = re.compile(r"(?i)(\bbearer\s+)\S+") +_URL_QUERY_RE = re.compile(r"https?://[^\s?]+\?[^\s]+") + + +class CodexSandboxConnection(Protocol): + """The narrow app-server surface required by the Studio adapter.""" + + thread_id: str + + async def connect(self) -> None: ... + + async def stream_turn( + self, + prompt: str, + skill_ids: tuple[str, ...] = (), + *, + permissions: CodexPermissionSettings | None = None, + timeout_seconds: float | None = None, + output_schema: dict[str, object] | None = None, + ) -> AsyncIterator[CodexAppServerEvent]: + if False: + yield CodexAppServerEvent() + + async def close(self) -> None: ... + + +@dataclass +class _CodexConnectionEntry: + connection: CodexSandboxConnection + lock: asyncio.Lock + + +class CodexSandboxDelegate: + """Reuse one Codex app-server thread for each mounted Studio session.""" + + def __init__( + self, + target_resolver: SandboxTargetResolver, + *, + connection_factory: Callable[[str], CodexSandboxConnection] = ( + CodexAppServerSession + ), + readiness_probe: Callable[[SandboxExecutionTarget], Awaitable[bool]] + | None = None, + sleep: Callable[[float], Awaitable[Any]] = asyncio.sleep, + ) -> None: + self._target_resolver = target_resolver + self._connection_factory = connection_factory + self._readiness_probe = readiness_probe or ( + _codex_app_server_ready + if connection_factory is CodexAppServerSession + else _always_ready + ) + self._sleep = sleep + self._connections: dict[ + tuple[str, str, str, str, str, str], _CodexConnectionEntry + ] = {} + self._connections_lock = asyncio.Lock() + + async def execute( + self, + mount: SessionEnvironmentMount, + task: str, + context: StudioToolExecutionContext, + ) -> dict[str, Any]: + """Run one delegated task and forward every app-server event.""" + + _require_codex_mount(mount) + target = await self._target_resolver.resolve(mount, context) + prompt = _delegated_prompt(mount, task) + text_parts: list[str] = [] + activity_events: list[dict[str, Any]] = [] + text_event_id = f"assistant:{context.tool_request_id or context.run_id}" + + try: + entry = await self._ready_connection(target, mount, context) + except CodexAppServerTransportError as error: + failure = await _report_failure(context, mount, "Codex Sandbox 连接失败") + _append_activity_event(activity_events, failure) + raise StudioToolRuntimeError( + "Codex Sandbox 服务尚未就绪,多次连接仍失败,请稍后重试。", + content=_activity_result( + mount, + context, + activity_events, + ok=False, + ), + ) from error + + async with entry.lock: + _append_activity_event( + activity_events, + await _report( + context, + mount, + { + "id": f"turn:{context.tool_request_id or context.run_id}", + "kind": "status", + "status": "running", + "text": "Codex Sandbox 已接收任务", + "agentSessionId": context.session_id, + "sandboxSessionId": target.session_id, + "threadId": entry.connection.thread_id, + }, + ), + ) + try: + async for event in entry.connection.stream_turn( + prompt, + permissions=_CODEX_PERMISSIONS, + ): + if ( + event.kind == "text" + and event.text + and not _append_text_part(text_parts, event.text) + ): + continue + if event.kind in {"assistant_final", "final"}: + continue + _append_activity_event( + activity_events, + await _report( + context, + mount, + _progress_event(event, fallback_id=text_event_id), + ), + ) + except CodexAppServerTurnTimeoutError as error: + failure = await _report_failure( + context, mount, "Codex Sandbox 执行超时" + ) + _append_activity_event(activity_events, failure) + raise StudioToolRuntimeError( + "Codex Sandbox 长时间没有返回新进度,请重试。", + content=_activity_result( + mount, + context, + activity_events, + target=target, + thread_id=entry.connection.thread_id, + ok=False, + ), + ) from error + except CodexAppServerTransportError as error: + failure = await _report_failure( + context, mount, "Codex Sandbox 连接中断" + ) + _append_activity_event(activity_events, failure) + await self._discard(target, mount, context, entry) + raise StudioToolRuntimeError( + "Codex Sandbox 连接中断,请重试本次任务。", + content=_activity_result( + mount, + context, + activity_events, + target=target, + thread_id=entry.connection.thread_id, + ok=False, + ), + ) from error + except CodexAppServerError as error: + failure = await _report_failure( + context, mount, "Codex Sandbox 执行失败" + ) + _append_activity_event(activity_events, failure) + raise StudioToolRuntimeError( + "Codex Sandbox 未能完成任务,请检查任务描述后重试。", + content=_activity_result( + mount, + context, + activity_events, + target=target, + thread_id=entry.connection.thread_id, + ok=False, + ), + ) from error + + _append_activity_event( + activity_events, + await _report( + context, + mount, + { + "id": f"turn:{context.tool_request_id or context.run_id}", + "kind": "status", + "status": "completed", + "text": "Codex Sandbox 已完成任务", + "agentSessionId": context.session_id, + "sandboxSessionId": target.session_id, + "threadId": entry.connection.thread_id, + }, + ), + ) + message = _redact_text("".join(text_parts).strip()) + return { + "ok": True, + "environment_id": mount.environment_id, + "agent_session_id": context.session_id, + "sandbox_session_id": target.session_id, + "thread_id": entry.connection.thread_id, + "codex_activity": _activity_snapshot( + mount, + context, + activity_events, + target=target, + thread_id=entry.connection.thread_id, + ), + "message": ( + message[-_MAX_RESULT_CHARACTERS:] + if message + else "Codex Sandbox 已完成任务。" + ), + } + + async def _ready_connection( + self, + target: SandboxExecutionTarget, + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + ) -> _CodexConnectionEntry: + attempts = len(_CONNECT_RETRY_DELAYS_SECONDS) + 1 + for attempt in range(1, attempts + 1): + entry: _CodexConnectionEntry | None = None + try: + if not await self._readiness_probe(target): + raise CodexAppServerTransportError( + "Codex app-server readiness check did not pass." + ) + entry = await self._connection(target, mount, context) + # Several outer-agent runs can target the same mounted Sandbox. + # Serialize the initial handshake with turns so one failed opener + # cannot close the transport while another caller is connecting. + async with entry.lock: + await entry.connection.connect() + return entry + except CodexAppServerTransportError as error: + logger.warning( + "Codex Sandbox app-server connection failed " + "environment_id_prefix=%s attempt=%d/%d error_type=%s", + mount.environment_id[:8], + attempt, + attempts, + type(error).__name__, + ) + if entry is not None: + await self._discard(target, mount, context, entry) + if attempt >= attempts: + raise + await _report( + context, + mount, + { + "id": f"turn:{context.tool_request_id or context.run_id}", + "kind": "status", + "status": "running", + "text": ( + "Codex Sandbox 正在启动," + f"准备第 {attempt + 1}/{attempts} 次连接" + ), + }, + ) + await self._sleep(_CONNECT_RETRY_DELAYS_SECONDS[attempt - 1]) + raise AssertionError("unreachable") + + async def _connection( + self, + target: SandboxExecutionTarget, + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + ) -> _CodexConnectionEntry: + key = _connection_key(target, mount, context) + async with self._connections_lock: + entry = self._connections.get(key) + if entry is None: + entry = _CodexConnectionEntry( + connection=self._connection_factory(target.endpoint), + lock=asyncio.Lock(), + ) + self._connections[key] = entry + return entry + + async def _discard( + self, + target: SandboxExecutionTarget, + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + entry: _CodexConnectionEntry, + ) -> None: + key = _connection_key(target, mount, context) + async with self._connections_lock: + if self._connections.get(key) is entry: + self._connections.pop(key, None) + try: + await entry.connection.close() + except Exception as error: # noqa: BLE001 - preserve the primary failure + logger.warning( + "Codex Sandbox connection cleanup failed " + "environment_id_prefix=%s error_type=%s", + mount.environment_id[:8], + type(error).__name__, + ) + + async def close(self) -> None: + """Close every cached app-server transport during Studio shutdown.""" + + async with self._connections_lock: + entries = tuple(self._connections.values()) + self._connections.clear() + if entries: + await asyncio.gather( + *(entry.connection.close() for entry in entries), + return_exceptions=True, + ) + + +def register_codex_sandbox_tool( + registry: StudioToolRegistry, + *, + mounts: SessionEnvironmentMountRegistry, + delegate: CodexSandboxDelegate, +) -> None: + """Register the context-bound Codex delegation tool.""" + + async def execute( + arguments: dict[str, Any], + context: StudioToolExecutionContext, + ) -> dict[str, Any]: + try: + mount = mounts.get(context, str(arguments["environment_id"])) + return await delegate.execute(mount, str(arguments["task"]), context) + except StudioToolExecutionError: + raise + except (KeyError, TypeError, ValueError, SandboxResolutionError) as error: + raise StudioToolExecutionError(str(error)) from error + except Exception as error: + raise StudioToolRuntimeError( + "Codex Sandbox 当前不可用,请稍后重试。" + ) from error + + registry.register( + StudioTool( + name="delegate_to_codex_sandbox", + display_name="交给 Codex Sandbox", + description=( + "Delegate a complete coding, review, authoring, or engineering task " + "to Codex inside a mounted Codex Sandbox environment. Use this tool " + "instead of execute_in_sandbox when the selected environment has " + "baseEnvironment=codex-sandbox. Pass a self-contained task with the " + "desired outcome, constraints, relevant context, and verification " + "requirements; Codex will inspect the environment and invoke its " + "installed CLIs end to end. This is execution within an already " + "mounted environment, not creation of a new agent." + ), + input_schema={ + "type": "object", + "properties": { + "environment_id": { + "type": "string", + "minLength": 32, + "maxLength": 32, + "description": ( + "ID of the Codex environment returned by list_envs." + ), + }, + "task": { + "type": "string", + "minLength": 1, + "maxLength": 32_768, + "description": ( + "A self-contained task for the inner Codex agent, " + "including " + "the expected deliverable and how to verify it." + ), + }, + }, + "required": ["environment_id", "task"], + "additionalProperties": False, + }, + executor=execute, + executor_revision="codex-app-server-v1", + # Keep the catalog compatible with runtimes that still enforce the + # original two-minute Studio tool protocol ceiling. The app-server + # turn itself retains its independent inactivity timeout. + timeout_ms=120_000, + idempotent=False, + risk_level="high", + requires_context=True, + ) + ) + + +async def _always_ready(target: SandboxExecutionTarget) -> bool: + del target + return True + + +async def _codex_app_server_ready(target: SandboxExecutionTarget) -> bool: + """Probe the app-server without exposing the private Sandbox endpoint.""" + + url = sandbox_service_url(target.endpoint, "/v1/codex/app-server/readyz") + try: + async with httpx.AsyncClient( + timeout=_READINESS_TIMEOUT_SECONDS, + follow_redirects=False, + trust_env=False, + ) as client: + response = await client.get(url, headers=dict(target.headers or {})) + except httpx.HTTPError: + return False + return 200 <= response.status_code < 300 + + +def _require_codex_mount(mount: SessionEnvironmentMount) -> None: + spec = mount.manifest.get("spec") + base_environment = ( + spec.get("baseEnvironment") if isinstance(spec, Mapping) else None + ) + if base_environment != "codex-sandbox": + raise ValueError("所选环境不是 Codex Sandbox,无法委派 Codex 任务。") + + +def _delegated_prompt(mount: SessionEnvironmentMount, task: str) -> str: + capabilities = [] + spec = mount.manifest.get("spec") + if isinstance(spec, Mapping) and isinstance(spec.get("capabilities"), list): + capabilities = [ + value.strip() + for value in spec["capabilities"] + if isinstance(value, str) and value.strip() + ] + context_lines = [ + "You are executing inside a prebuilt AgentKit environment.", + f"Environment name: {mount.name or mount.environment_id}", + ] + if mount.description: + context_lines.append(f"Environment description: {mount.description}") + if capabilities: + context_lines.append("Available capabilities: " + ", ".join(capabilities)) + context_lines.extend( + [ + ( + "Inspect the workspace and installed CLI help when needed, then " + "complete the task end to end. Run relevant verification before " + "reporting the result." + ), + ( + "Batch related non-destructive shell checks into as few tool calls " + "as practical, avoid repeating successful checks, and return a " + "concise final result as soon as verification is complete." + ), + "Do not ask the outer agent to run commands that you can run here.", + "", + "Task from the outer agent:", + task.strip(), + ] + ) + return "\n".join(context_lines) + + +def _progress_event( + event: CodexAppServerEvent, + *, + fallback_id: str, +) -> dict[str, Any]: + event_id = event.item_id or event.turn_id or fallback_id + payload: dict[str, Any] = { + "id": event_id, + "kind": event.kind, + "status": event.status, + } + if event.text: + if event.kind == "text" and not event.item_id: + payload["delta"] = event.text + else: + payload["text"] = event.text + if event.name: + payload["name"] = event.name + if event.arguments is not None: + payload["arguments"] = event.arguments + if event.response is not None: + payload["response"] = event.response + if event.approval is not None: + payload["approval"] = event.approval.public_dict() + if event.approval_resolved_id: + payload["approvalResolvedId"] = event.approval_resolved_id + if event.usage is not None: + payload["usage"] = event.usage.public_dict() + if event.thread_total is not None: + payload["threadTotal"] = event.thread_total.public_dict() + if event.model_context_window is not None: + payload["modelContextWindow"] = event.model_context_window + return payload + + +def _append_text_part(parts: list[str], value: str) -> bool: + """Append streamed text while dropping repeated completed-message payloads.""" + + if parts and parts[-1] == value: + return False + parts.append(value) + return True + + +async def _report( + context: StudioToolExecutionContext, + mount: SessionEnvironmentMount, + event: dict[str, Any], +) -> dict[str, Any]: + safe_event = _bounded_progress_event(event) + if context.report_progress is not None: + await context.report_progress( + { + "kind": "codex", + "title": _redact_text(mount.name or "Codex Sandbox")[:200], + "event": safe_event, + } + ) + return safe_event + + +async def _report_failure( + context: StudioToolExecutionContext, + mount: SessionEnvironmentMount, + message: str, +) -> dict[str, Any]: + return await _report( + context, + mount, + { + "id": f"turn:{context.tool_request_id or context.run_id}", + "kind": "status", + "status": "failed", + "text": message, + }, + ) + + +def _append_activity_event( + events: list[dict[str, Any]], + event: dict[str, Any], +) -> None: + events.append(event) + while ( + len(events) > _MAX_ACTIVITY_EVENTS or _json_size(events) > _MAX_ACTIVITY_BYTES + ): + events.pop(0) + + +def _json_size(value: Any) -> int: + return len( + json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) + + +def _activity_snapshot( + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + events: list[dict[str, Any]], + *, + target: SandboxExecutionTarget | None = None, + thread_id: str = "", +) -> dict[str, Any]: + return { + "title": _redact_text(mount.name or "Codex Sandbox")[:200], + "agent_session_id": context.session_id, + "sandbox_session_id": target.session_id if target is not None else "", + "thread_id": thread_id, + "events": list(events), + } + + +def _activity_result( + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + events: list[dict[str, Any]], + *, + target: SandboxExecutionTarget | None = None, + thread_id: str = "", + ok: bool, +) -> dict[str, Any]: + return { + "ok": ok, + "environment_id": mount.environment_id, + "codex_activity": _activity_snapshot( + mount, + context, + events, + target=target, + thread_id=thread_id, + ), + } + + +def _connection_key( + target: SandboxExecutionTarget, + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, +) -> tuple[str, str, str, str, str, str]: + return ( + context.runtime_id, + context.app_name, + context.user_id, + context.session_id, + mount.environment_id, + target.session_id, + ) + + +def _bounded_progress_event(event: Mapping[str, Any]) -> dict[str, Any]: + sanitized = _safe_progress_value(event) + if not isinstance(sanitized, dict): + return { + "id": "codex-progress", + "kind": "status", + "status": "failed", + "text": "Codex Sandbox 返回了无效进度。", + } + encoded = json.dumps( + sanitized, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded) <= _MAX_PROGRESS_BYTES: + return sanitized + preview = _redact_text( + encoded[: _MAX_PROGRESS_BYTES // 2].decode("utf-8", errors="replace") + ) + return { + "id": str(sanitized.get("id") or "codex-progress")[:200], + "kind": str(sanitized.get("kind") or "status")[:100], + "status": str(sanitized.get("status") or "running")[:100], + "name": str(sanitized.get("name") or "Codex 输出")[:200], + "response": { + "truncated": True, + "preview": preview, + }, + } + + +def _safe_progress_value(value: Any, *, depth: int = 0) -> Any: + if depth >= 8: + return "[truncated]" + if isinstance(value, str): + return _redact_text(value)[:_MAX_PROGRESS_STRING_CHARACTERS] + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for index, (raw_key, item) in enumerate(value.items()): + if index >= _MAX_PROGRESS_COLLECTION_ITEMS: + result["truncated"] = True + break + key = str(raw_key)[:200] + result[key] = ( + "***" + if _SENSITIVE_KEY_RE.search(key) + else _safe_progress_value(item, depth=depth + 1) + ) + return result + if isinstance(value, (list, tuple)): + items = [ + _safe_progress_value(item, depth=depth + 1) + for item in value[:_MAX_PROGRESS_COLLECTION_ITEMS] + ] + if len(value) > _MAX_PROGRESS_COLLECTION_ITEMS: + items.append("[truncated]") + return items + return _redact_text(str(value))[:_MAX_PROGRESS_STRING_CHARACTERS] + + +def _redact_text(value: str) -> str: + result = value + for key, secret in os.environ.items(): + if secret and len(secret) >= 8 and _SENSITIVE_KEY_RE.search(key): + result = result.replace(secret, "***") + result = _BEARER_RE.sub(r"\1***", result) + result = _SENSITIVE_VALUE_RE.sub(r"\1***", result) + return _URL_QUERY_RE.sub("[sandbox endpoint]", result) + + +__all__ = [ + "CodexSandboxConnection", + "CodexSandboxDelegate", + "register_codex_sandbox_tool", +] diff --git a/frontend/server/studio_tools/connector.py b/frontend/server/studio_tools/connector.py index 73cb668ed..5d4494519 100644 --- a/frontend/server/studio_tools/connector.py +++ b/frontend/server/studio_tools/connector.py @@ -36,6 +36,7 @@ StudioToolCatalogSnapshot, StudioToolExecutionContext, StudioToolExecutionError, + StudioToolRuntimeError, ) from veadk.integrations.agentkit.studio_channel.protocol import ( CAPABILITIES_SUFFIX, @@ -233,6 +234,8 @@ async def _execute_tool(self, message: dict[str, Any]) -> None: "Studio tool arguments must be an object." ) tool_name = str(message.get("tool_name") or "") + function_call_id = str(message.get("function_call_id") or "").strip() + progress_request_id = function_call_id or request_id async def report_progress(progress: dict[str, Any]) -> None: event = { @@ -245,9 +248,9 @@ async def report_progress(progress: dict[str, Any]) -> None: { "partMetadata": { "veadkStudioToolProgress": { - "toolName": tool_name, - "requestId": request_id, **progress, + "toolName": tool_name, + "requestId": progress_request_id, } } } @@ -273,6 +276,11 @@ async def report_progress(progress: dict[str, Any]) -> None: context=execution_context, ) content = _bounded_tool_result(content) + except StudioToolRuntimeError as exc: + status = "error" + error = str(exc) + if exc.content is not None: + content = _bounded_tool_result(exc.content) except StudioToolExecutionError as exc: status = "denied" error = str(exc) diff --git a/frontend/server/studio_tools/registry.py b/frontend/server/studio_tools/registry.py index cc5fb5d4b..50f145210 100644 --- a/frontend/server/studio_tools/registry.py +++ b/frontend/server/studio_tools/registry.py @@ -50,6 +50,14 @@ class StudioToolExecutionError(RuntimeError): """A safe error that can be returned across the Studio channel.""" +class StudioToolRuntimeError(StudioToolExecutionError): + """A safe operational failure, distinct from a denied invocation.""" + + def __init__(self, message: str, *, content: Any = None) -> None: + super().__init__(message) + self.content = content + + @dataclass(frozen=True) class StudioToolExecutionContext: """Server-derived identity and run scope available only to BFF executors.""" @@ -111,7 +119,8 @@ def register(self, tool: StudioTool) -> None: key = (manifest.name, manifest.executor_revision) if key in self._tools: raise ValueError( - f"Studio tool already registered: {manifest.name}@{manifest.executor_revision}" + f"Studio tool already registered: {manifest.name}@" + f"{manifest.executor_revision}" ) self._tools[key] = tool self._latest[manifest.name] = manifest.executor_revision @@ -266,16 +275,20 @@ def build_studio_tool_registry( """Build the complete Studio BFF tool registry.""" registry = StudioToolRegistry() - from frontend.server.studio_tools.veadk_builtin_tools import ( - register_veadk_builtin_tools, - ) from frontend.server.studio_tools.branch_compare import ( register_branch_compare_tool, ) + from frontend.server.studio_tools.veadk_builtin_tools import ( + register_veadk_builtin_tools, + ) register_veadk_builtin_tools(registry, media_service=media_service) register_branch_compare_tool(registry) if environment_mounts is not None and sandbox_target_resolver is not None: + from frontend.server.studio_tools.codex_sandbox import ( + CodexSandboxDelegate, + register_codex_sandbox_tool, + ) from frontend.server.studio_tools.sandbox_shell import ( register_sandbox_shell_tool, ) @@ -285,6 +298,11 @@ def build_studio_tool_registry( mounts=environment_mounts, target_resolver=sandbox_target_resolver, ) + register_codex_sandbox_tool( + registry, + mounts=environment_mounts, + delegate=CodexSandboxDelegate(sandbox_target_resolver), + ) from frontend.server.studio_tools.extensions import ( register_studio_tool_extensions, ) diff --git a/frontend/server/studio_tools/sandbox_shell.py b/frontend/server/studio_tools/sandbox_shell.py index 10da58f7a..86ba00629 100644 --- a/frontend/server/studio_tools/sandbox_shell.py +++ b/frontend/server/studio_tools/sandbox_shell.py @@ -76,8 +76,7 @@ class SandboxExecutionTarget: @dataclass(frozen=True) class _CachedTarget: - image: str - tool_id: str + mount_identity: tuple[str, str, str, str, str, str, str] target: SandboxExecutionTarget @@ -103,22 +102,15 @@ async def resolve( context: StudioToolExecutionContext, ) -> SandboxExecutionTarget: key = (*_context_key(context), mount.environment_id) + mount_identity = _mount_identity(mount) cached = self._targets.get(key) - if ( - cached is not None - and cached.image == mount.image - and cached.tool_id == mount.tool_id - ): + if cached is not None and cached.mount_identity == mount_identity: return cached.target lock = self._locks.setdefault(key, asyncio.Lock()) async with lock: cached = self._targets.get(key) - if ( - cached is not None - and cached.image == mount.image - and cached.tool_id == mount.tool_id - ): + if cached is not None and cached.mount_identity == mount_identity: return cached.target if not mount.tool_id or mount.tool_status != _READY_STATUS: @@ -131,8 +123,7 @@ async def resolve( await _require_ready_tool(client, tool_id, mount.image) target = await self._session_for_mount(client, tool_id, mount, context) self._targets[key] = _CachedTarget( - image=mount.image, - tool_id=tool_id, + mount_identity=mount_identity, target=target, ) return target @@ -178,17 +169,18 @@ async def _session_for_mount( session_id = str(getattr(existing, "session_id", "") or "").strip() endpoint = str(getattr(existing, "endpoint", "") or "").strip() + status = str(getattr(existing, "status", "") or "").strip().lower() if not session_id: raise RuntimeError("AgentKit did not return a Sandbox Session ID.") deadline = time.monotonic() + _SESSION_READY_TIMEOUT_SECONDS - while not endpoint: + while not endpoint or status != _READY_STATUS: session = await asyncio.to_thread( client.get_session, tools_types.GetSessionRequest(ToolId=tool_id, SessionId=session_id), ) status = str(getattr(session, "status", "") or "").strip().lower() endpoint = str(getattr(session, "endpoint", "") or "").strip() - if endpoint and status in {"", _READY_STATUS}: + if endpoint and status == _READY_STATUS: break if status in _FAILED_TOOL_STATUSES: raise RuntimeError("AgentKit Sandbox Session failed to become ready.") @@ -227,6 +219,7 @@ async def list_envs( "environment_version_id": mount.environment_version_id, "name": mount.name, "description": mount.description, + "base_environment": _manifest_base_environment(mount.manifest), "capabilities": _manifest_capabilities(mount.manifest), } for mount in mounted @@ -251,6 +244,11 @@ async def execute( mount = mounts.get(context, str(arguments["environment_id"])) except (KeyError, TypeError, ValueError) as error: raise StudioToolExecutionError(str(error)) from error + if _manifest_base_environment(mount.manifest) == "codex-sandbox": + raise StudioToolExecutionError( + "Codex Sandbox 环境只允许通过 delegate_to_codex_sandbox " + "执行任务,不能回退到 execute_in_sandbox。" + ) try: target = await target_resolver.resolve(mount, context) command_arguments = { @@ -290,7 +288,9 @@ async def execute( "disqualifying. Requirement/design/ADR work matches authoring/design, " "review/verification matches review, and implementation/fix/test work " "matches engineering. A matching mounted " - "environment has priority over creating or delegating to a new agent." + "environment has priority over creating or delegating to a new agent. " + "When base_environment is codex-sandbox, use " + "delegate_to_codex_sandbox instead of individual shell commands." ), input_schema={ "type": "object", @@ -333,7 +333,10 @@ async def execute( display_name="在环境中执行命令", description=( "Execute a non-interactive shell command, including installed CLI " - "tools, inside the environment mounted to this conversation. Use a " + "tools, inside a non-Codex environment mounted to this conversation. " + "Never use this tool for an environment whose base_environment is " + "codex-sandbox; delegate_to_codex_sandbox is the only supported " + "execution path for those environments. Use a " "matching mounted environment to complete the task instead of " "creating a new agent unless the user explicitly requests agent " "creation or delegation. When the user explicitly names a mounted " @@ -544,7 +547,7 @@ def _user_session_id( context: StudioToolExecutionContext, mount: SessionEnvironmentMount, ) -> str: - value = "\x00".join((*_context_key(context), mount.environment_id, mount.image)) + value = "\x00".join((*_context_key(context), *_mount_identity(mount))) return "studio-env-" + hashlib.sha256(value.encode()).hexdigest()[:32] @@ -559,6 +562,22 @@ def _context_key( ) +def _mount_identity( + mount: SessionEnvironmentMount, +) -> tuple[str, str, str, str, str, str, str]: + """Return every immutable input that identifies one mounted Sandbox.""" + + return ( + mount.provider, + mount.region, + mount.environment_id, + mount.environment_version_id, + mount.mount_instance_id, + mount.tool_id, + mount.image, + ) + + def _manifest_capabilities(manifest: Mapping[str, Any]) -> list[str]: spec = manifest.get("spec") if not isinstance(spec, Mapping): @@ -569,6 +588,14 @@ def _manifest_capabilities(manifest: Mapping[str, Any]) -> list[str]: return [item for item in capabilities if isinstance(item, str)] +def _manifest_base_environment(manifest: Mapping[str, Any]) -> str: + spec = manifest.get("spec") + if not isinstance(spec, Mapping): + return "" + value = spec.get("baseEnvironment") + return value.strip() if isinstance(value, str) else "" + + def _find_session( client: Any, tools_types: Any, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e3c002fe..ee31a9a94 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -402,6 +402,7 @@ const ENVIRONMENT_STUDIO_TOOL_IDS = [ "list_envs", "get_env_manifest", "execute_in_sandbox", + "delegate_to_codex_sandbox", ] as const; function emptyInvocation(): FrontendInvocation { @@ -1374,6 +1375,7 @@ export default function App() { const [sessionWorkspaces, setSessionWorkspaces] = useState([]); const [sessionEnvironmentsLoading, setSessionEnvironmentsLoading] = useState(false); const [sessionEnvironmentsError, setSessionEnvironmentsError] = useState(""); + const sessionEnvironmentLoadAbortRef = useRef(null); const [environmentMountsBySession, setEnvironmentMountsBySession] = useState< Record >({}); @@ -5449,11 +5451,63 @@ export default function App() { studioToolRuntime?.runtimeId, ]); - useEffect(() => { + const refreshSessionEnvironments = useCallback(async () => { + sessionEnvironmentLoadAbortRef.current?.abort(); const controller = new AbortController(); - setSessionEnvironments([]); - setSessionWorkspaces([]); + sessionEnvironmentLoadAbortRef.current = controller; + setSessionEnvironmentsLoading(true); setSessionEnvironmentsError(""); + try { + const [items, workspaces] = await Promise.all([ + listEnvironments(controller.signal), + listWorkspaces(controller.signal), + ]); + if (controller.signal.aborted) return; + const availableEnvironments = items.filter((environment) => + ["aio-sandbox", "codex-sandbox"].includes(environment.baseEnvironment) && + environment.latestVersion?.status === "available" && + environment.latestVersion.toolStatus === "ready" && + Boolean(environment.latestVersion.toolId) + ); + const availableMountKeys = new Set(availableEnvironments.flatMap((environment) => + environment.latestVersion + ? [`${environment.id}\u0000${environment.latestVersion.versionId}`] + : [] + )); + const availableWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id)); + + // Replace the snapshot instead of merging it so deleted environments and + // workspaces disappear from both the picker and existing Session mounts. + setSessionEnvironments(availableEnvironments); + setSessionWorkspaces(workspaces); + setEnvironmentMountsBySession((current) => Object.fromEntries( + Object.entries(current).map(([key, selections]) => [ + key, + selections.filter((selection) => availableMountKeys.has( + `${selection.environment_id}\u0000${selection.environment_version_id}`, + )), + ]), + )); + setEnvironmentWorkspaceIdsBySession((current) => Object.fromEntries( + Object.entries(current).map(([key, workspaceIds]) => [ + key, + workspaceIds.filter((workspaceId) => availableWorkspaceIds.has(workspaceId)), + ]), + )); + } catch (cause) { + if (controller.signal.aborted) return; + setSessionEnvironmentsError( + cause instanceof Error ? cause.message : "读取环境失败", + ); + } finally { + if (sessionEnvironmentLoadAbortRef.current === controller) { + sessionEnvironmentLoadAbortRef.current = null; + setSessionEnvironmentsLoading(false); + } + } + }, []); + + useEffect(() => { if ( authStatus !== "authenticated" || !access || @@ -5461,40 +5515,26 @@ export default function App() { agentDetailTarget || !studioToolRuntime ) { + sessionEnvironmentLoadAbortRef.current?.abort(); + sessionEnvironmentLoadAbortRef.current = null; + setSessionEnvironments([]); + setSessionWorkspaces([]); + setSessionEnvironmentsError(""); setSessionEnvironmentsLoading(false); - return () => controller.abort(); + return; } - setSessionEnvironmentsLoading(true); - void Promise.all([ - listEnvironments(controller.signal), - listWorkspaces(controller.signal), - ]) - .then(([items, workspaces]) => { - if (controller.signal.aborted) return; - setSessionEnvironments(items.filter((environment) => - environment.baseEnvironment === "aio-sandbox" && - environment.latestVersion?.status === "available" && - environment.latestVersion.toolStatus === "ready" && - Boolean(environment.latestVersion.toolId) - )); - setSessionWorkspaces(workspaces); - }) - .catch((cause) => { - if (controller.signal.aborted) return; - setSessionEnvironmentsError( - cause instanceof Error ? cause.message : "读取环境失败", - ); - }) - .finally(() => { - if (!controller.signal.aborted) setSessionEnvironmentsLoading(false); - }); - return () => controller.abort(); + void refreshSessionEnvironments(); + return () => { + sessionEnvironmentLoadAbortRef.current?.abort(); + sessionEnvironmentLoadAbortRef.current = null; + }; }, [ access, agentDetailTarget, authStatus, environmentView, myAgents, + refreshSessionEnvironments, studioToolRuntime?.region, studioToolRuntime?.runtimeId, ]); @@ -7740,6 +7780,7 @@ export default function App() { ? updateSelectedEnvironments : undefined } + onEnvironmentsRefresh={refreshSessionEnvironments} /> )}
diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 1c553e84a..43865b1b8 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2107,11 +2107,13 @@ export interface SystemInfoResponse { } export type EnvironmentOperatingSystem = "ubuntu-22.04" | "ubuntu-24.04"; -export type EnvironmentBaseEnvironment = "ubuntu" | "aio-sandbox"; +export type EnvironmentBaseEnvironment = "ubuntu" | "aio-sandbox" | "codex-sandbox"; export type EnvironmentLanguage = "python-3.10" | "python-3.12"; export interface SessionEnvironmentMountSelection { environment_id: string; environment_version_id: string; + /** Identifies one continuous attachment; absent for legacy Studio clients. */ + mount_instance_id?: string; } export type EnvironmentBuildStatus = | "preparing" @@ -2459,7 +2461,7 @@ function environmentBuildVersion(value: unknown): EnvironmentBuildVersion | null }; } -function environmentManifest(value: unknown): EnvironmentManifest { +export function parseEnvironmentManifest(value: unknown): EnvironmentManifest { if (!value || typeof value !== "object") { throw new Error("环境 Manifest 响应格式无效"); } @@ -2474,7 +2476,9 @@ function environmentManifest(value: unknown): EnvironmentManifest { typeof candidate.metadata.description !== "string" || !candidate.spec || typeof candidate.spec.image !== "string" || - !["ubuntu", "aio-sandbox"].includes(candidate.spec.baseEnvironment) || + !["ubuntu", "aio-sandbox", "codex-sandbox"].includes( + candidate.spec.baseEnvironment, + ) || typeof candidate.spec.baseImage !== "string" || !["ubuntu-22.04", "ubuntu-24.04"].includes(candidate.spec.operatingSystem) || !["python-3.10", "python-3.12"].includes(candidate.spec.language) || @@ -2550,7 +2554,8 @@ function studioEnvironment(value: unknown): StudioEnvironment { typeof candidate.description !== "string" || (candidate.baseEnvironment !== undefined && candidate.baseEnvironment !== "ubuntu" && - candidate.baseEnvironment !== "aio-sandbox") || + candidate.baseEnvironment !== "aio-sandbox" && + candidate.baseEnvironment !== "codex-sandbox") || (candidate.operatingSystem !== "ubuntu-22.04" && candidate.operatingSystem !== "ubuntu-24.04") || (candidate.language !== "python-3.10" && candidate.language !== "python-3.12") || @@ -2565,7 +2570,11 @@ function studioEnvironment(value: unknown): StudioEnvironment { } return { ...candidate, - baseEnvironment: candidate.baseEnvironment === "aio-sandbox" ? "aio-sandbox" : "ubuntu", + baseEnvironment: candidate.baseEnvironment === "aio-sandbox" + ? "aio-sandbox" + : candidate.baseEnvironment === "codex-sandbox" + ? "codex-sandbox" + : "ubuntu", selectedSkills: candidate.selectedSkills ?? [], gitSource: environmentGitSource(candidate.gitSource), containerRepository: environmentContainerRepository(candidate.containerRepository), @@ -2812,12 +2821,21 @@ async function writeEnvironment( input: EnvironmentInput, signal?: AbortSignal, ): Promise { - const response = await apiFetch(path, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - signal, - }); + let response: Response; + try { + response = await apiFetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + if (error instanceof TypeError) { + throw new Error("无法连接 Studio 服务,请确认后端已启动后重试。"); + } + throw error; + } if (!response.ok) { throw new Error(await httpErrorMessage(response, "保存环境失败")); } @@ -2903,7 +2921,7 @@ export async function getEnvironmentManifest( if (!response.ok) { throw new Error(await httpErrorMessage(response, "读取环境 Manifest 失败")); } - return environmentManifest(await response.json()); + return parseEnvironmentManifest(await response.json()); } function environmentBuildResource(value: unknown): EnvironmentBuildResource { diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts index 6134779ce..0ebde7ef3 100644 --- a/frontend/src/blocks.ts +++ b/frontend/src/blocks.ts @@ -24,6 +24,12 @@ import { applyBranchCompareProgress, parseBranchCompareProgress, } from "./ui/builtin-tools/branchCompareData"; +import { + applyCodexSandboxProgress, + hydrateCodexSandboxActivity, + parseCodexSandboxProgress, +} from "./ui/builtin-tools/codexSandboxProgress"; +import type { CodexSandboxProgress } from "./ui/builtin-tools/codexSandboxProgress"; const A2UI_TOOL = "send_a2ui_json_to_client"; const VALIDATED_JSON_KEY = "validated_a2ui_json"; @@ -77,6 +83,14 @@ export interface IntelligentDevelopmentReleaseRef { files?: ProjectFile[]; } +export interface CodexSandboxActivity { + title: string; + agentSessionId?: string; + sandboxSessionId?: string; + threadId?: string; + items: Array<{ id: string; block: Block }>; +} + export type Block = | { kind: "progress"; text: string } | { kind: "thinking"; text: string; done: boolean } @@ -90,6 +104,7 @@ export type Block = done: boolean; status?: "running" | "completed" | "failed"; defaultOpen?: boolean; + codexActivity?: CodexSandboxActivity; } | { kind: "plan"; @@ -128,6 +143,7 @@ export type Block = export interface Acc { blocks: Block[]; liveStart: number; + pendingCodexProgress: CodexSandboxProgress[]; } export interface TurnMeta { @@ -161,7 +177,58 @@ export interface Turn { } export function emptyAcc(): Acc { - return { blocks: [], liveStart: 0 }; + return { blocks: [], liveStart: 0, pendingCodexProgress: [] }; +} + +const MAX_PENDING_CODEX_PROGRESS = 64; + +function applyCodexProgressToTool( + blocks: Block[], + progress: CodexSandboxProgress, +): "applied" | "completed" | "unmatched" { + let fallbackIndex = -1; + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const block = blocks[index]; + if (block.kind !== "tool" || block.name !== progress.toolName) continue; + if (block.callId === progress.requestId) { + if (block.done) return "completed"; + block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress); + block.status = progress.terminalStatus ?? "running"; + if (progress.terminalStatus) block.done = true; + return "applied"; + } + if (!block.done) { + if (fallbackIndex >= 0) return "unmatched"; + fallbackIndex = index; + } + } + // Older Studio-channel runtimes reported their transport request id instead + // of the outer ADK function-call id. Bind only when the unfinished tool is + // unambiguous; exact ids above always win when calls overlap. + if (fallbackIndex >= 0) { + const block = blocks[fallbackIndex]; + if (block.kind !== "tool") return "unmatched"; + block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress); + block.status = progress.terminalStatus ?? "running"; + if (progress.terminalStatus) block.done = true; + return "applied"; + } + return "unmatched"; +} + +function codexResponseStatus(response: unknown): "completed" | "failed" { + if (!response || typeof response !== "object" || Array.isArray(response)) { + return "completed"; + } + const result = response as Record; + const status = typeof result.status === "string" ? result.status.toLowerCase() : ""; + if ( + result.ok === false + || ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(status) + ) { + return "failed"; + } + return "completed"; } const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call; @@ -319,6 +386,7 @@ function closeThinking(blocks: Block[]) { export function applyEvent(acc: Acc, ev: AdkEvent): Acc { const blocks = acc.blocks.map((b) => ({ ...b })); let liveStart = acc.liveStart; + let pendingCodexProgress = acc.pendingCodexProgress.slice(); const parts = ev.content?.parts ?? []; const progressUpdates = parts.flatMap((part) => { const progress = parseBranchCompareProgress( @@ -326,7 +394,13 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { ); return progress ? [progress] : []; }); - if (progressUpdates.length > 0) { + const codexProgressUpdates = parts.flatMap((part) => { + const progress = parseCodexSandboxProgress( + part.partMetadata ?? part.part_metadata, + ); + return progress ? [progress] : []; + }); + if (progressUpdates.length > 0 || codexProgressUpdates.length > 0) { for (const progress of progressUpdates) { for (let index = blocks.length - 1; index >= 0; index -= 1) { const block = blocks[index]; @@ -343,7 +417,14 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { break; } } - return { blocks, liveStart }; + for (const progress of codexProgressUpdates) { + const outcome = applyCodexProgressToTool(blocks, progress); + if (outcome === "unmatched") { + pendingCodexProgress = [...pendingCodexProgress, progress] + .slice(-MAX_PENDING_CODEX_PROGRESS); + } + } + return { blocks, liveStart, pendingCodexProgress }; } const hasFn = parts.some((p) => fnCall(p) || fnResp(p)); @@ -354,7 +435,7 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { if (typeof text === "string" && text) appendText(blocks, p.thought ? "thinking" : "text", text); } - return { blocks, liveStart }; + return { blocks, liveStart, pendingCodexProgress }; } // Consolidated / final event: drop the live preview and append authoritative @@ -396,13 +477,28 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { done: false, }); } else { - blocks.push({ + const toolBlock: Extract = { kind: "tool", name: fc.name ?? "", callId: fc.id, args: fc.args, done: false, - }); + }; + blocks.push(toolBlock); + if (toolBlock.callId) { + const stillPending: CodexSandboxProgress[] = []; + for (const progress of pendingCodexProgress) { + if ( + progress.toolName === toolBlock.name + && progress.requestId === toolBlock.callId + ) { + applyCodexProgressToTool(blocks, progress); + } else { + stillPending.push(progress); + } + } + pendingCodexProgress = stillPending; + } } } else if (fr) { closeThinking(blocks); @@ -427,14 +523,22 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { } for (let i = blocks.length - 1; i >= 0; i--) { const b = blocks[i]; + const isCodexTool = b.kind === "tool" && b.name === "delegate_to_codex_sandbox"; if ( b.kind === "tool" - && !b.done + && (!b.done || isCodexTool) && b.name === fr.name && (!fr.id || !b.callId || b.callId === fr.id) ) { b.done = true; b.response = fr.response; + if (isCodexTool) { + b.codexActivity = hydrateCodexSandboxActivity( + b.codexActivity, + fr.response, + ); + b.status = codexResponseStatus(fr.response); + } break; } } @@ -457,7 +561,7 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { } closeThinking(blocks); // a consolidated thinking segment is complete liveStart = blocks.length; - return { blocks, liveStart }; + return { blocks, liveStart, pendingCodexProgress }; } /** Replay stored session events into chat turns (for history). */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 914d87f94..e7409869c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1476,6 +1476,116 @@ body { margin: 20px -16px 0; } +.codex-sandbox-run { + isolation: isolate; + position: relative; + width: 100%; + min-width: 0; + margin: 20px 0 8px; + padding: 28px 14px 12px; + border: 1px solid hsl(215 20% 88% / 0.82); + border-radius: 14px; + background: + radial-gradient(circle at 12% 8%, hsl(210 38% 92% / 0.5), transparent 38%), + radial-gradient(circle at 88% 78%, hsl(220 22% 91% / 0.38), transparent 42%), + hsl(var(--background) / 0.56); +} +.codex-sandbox-run__label { + position: absolute; + top: 0; + left: 12px; + display: inline-flex; + min-height: 34px; + max-width: calc(100% - 24px); + padding: 3px 9px 3px 4px; + align-items: center; + gap: 8px; + border: 1px solid hsl(215 18% 86%); + border-radius: 10px; + background: hsl(var(--background)); + transform: translateY(-50%); +} +.codex-sandbox-run__badge { + display: inline-flex; + height: 26px; + padding: 0 8px 0 6px; + flex: 0 0 auto; + align-items: center; + gap: 5px; + border-radius: 7px; + background: hsl(210 22% 95%); + color: hsl(215 12% 43%); + font-size: 12px; + font-weight: 400; + white-space: nowrap; +} +.codex-sandbox-run__badge svg { + width: 15px; + height: 15px; + flex: 0 0 15px; +} +.codex-sandbox-run__title { + min-width: 0; + overflow: hidden; + color: hsl(var(--foreground)); + font-size: 14px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-sandbox-run__identity { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 12px; + margin: 0 0 10px; + padding: 0 2px 10px; + border-bottom: 1px solid hsl(var(--border)); +} +.codex-sandbox-run__identity > div { + min-width: 0; +} +.codex-sandbox-run__identity dt, +.codex-sandbox-run__identity dd { + margin: 0; +} +.codex-sandbox-run__identity dt { + color: hsl(var(--muted-foreground)); + font-size: 11px; + line-height: 1.45; +} +.codex-sandbox-run__identity dd { + overflow: hidden; + color: hsl(var(--foreground)); + font-size: 12px; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-sandbox-run__stream { + display: flex; + max-height: min(520px, 55vh); + min-width: 0; + padding-right: 2px; + overflow: auto; + flex-direction: column; + gap: 6px; +} +.codex-sandbox-run__stream .tool-detail, +.codex-sandbox-run__stream .tool-section, +.codex-sandbox-run__stream .tool-args { + min-width: 0; + max-width: 100%; +} +.codex-sandbox-run__stream .tool-args { + overflow: auto; +} +.codex-sandbox-run__empty { + min-height: 32px; + color: hsl(var(--muted-foreground)); + font-size: 13.5px; + line-height: 32px; +} + @media (max-width: 700px) { .turn--subagent { width: 100%; @@ -1484,6 +1594,9 @@ body { .turn--subagent:has(> .turn-meta) { padding-bottom: 0; } .turn--subagent .turn-meta { margin-right: -10px; margin-left: -10px; } .subagent-run-label { left: 10px; max-width: calc(100% - 20px); } + .codex-sandbox-run { padding-right: 10px; padding-left: 10px; } + .codex-sandbox-run__label { left: 10px; max-width: calc(100% - 20px); } + .codex-sandbox-run__identity { grid-template-columns: minmax(0, 1fr); } } .bubble { line-height: 1.65; font-size: 14.5px; } @@ -2883,6 +2996,7 @@ body { } .composer--new-chat .composer-menu-wrap { position: absolute; + z-index: 5; bottom: 10px; left: 10px; height: 36px; diff --git a/frontend/src/ui/AgentTopology.tsx b/frontend/src/ui/AgentTopology.tsx index 2c036d106..813729c6d 100644 --- a/frontend/src/ui/AgentTopology.tsx +++ b/frontend/src/ui/AgentTopology.tsx @@ -127,6 +127,7 @@ interface AgentInfoPanelProps { value: SessionEnvironmentMountSelection[], workspaceIds?: string[], ) => void; + onEnvironmentsRefresh?: () => void | Promise; } /** Agent metadata and optional multi-Agent topology shown in the conversation's @@ -152,6 +153,7 @@ export function AgentInfoPanel({ environmentsDisabled = false, environmentsError = "", onEnvironmentsChange, + onEnvironmentsRefresh, }: AgentInfoPanelProps) { const [dialog, setDialog] = useState<"tool" | null>(null); const [canvasExpanded, setCanvasExpanded] = useState(false); @@ -371,6 +373,7 @@ export function AgentInfoPanel({ disabled={environmentsDisabled} error={environmentsError} onChange={onEnvironmentsChange} + onRefresh={onEnvironmentsRefresh} /> )} @@ -479,6 +482,7 @@ export function AgentInfoDrawer({ environmentsDisabled, environmentsError, onEnvironmentsChange, + onEnvironmentsRefresh, onClose, returnFocusRef, }: { @@ -506,6 +510,7 @@ export function AgentInfoDrawer({ value: SessionEnvironmentMountSelection[], workspaceIds?: string[], ) => void; + onEnvironmentsRefresh?: () => void | Promise; onClose: () => void; returnFocusRef: RefObject; }) { @@ -573,6 +578,7 @@ export function AgentInfoDrawer({ environmentsDisabled={environmentsDisabled} environmentsError={environmentsError} onEnvironmentsChange={onEnvironmentsChange} + onEnvironmentsRefresh={onEnvironmentsRefresh} variant="drawer" /> ) : ( diff --git a/frontend/src/ui/Blocks.tsx b/frontend/src/ui/Blocks.tsx index cc5c5330b..c2bc47a8e 100644 --- a/frontend/src/ui/Blocks.tsx +++ b/frontend/src/ui/Blocks.tsx @@ -158,6 +158,46 @@ function PlanIcon() { ); } +function SandboxHandoffIcon() { + return ( + + ); +} + +function CodexSandboxIdentity({ + activity, +}: { + activity: NonNullable["codexActivity"]>; +}) { + const details: Array<[string, string | undefined]> = [ + ["Agent Session", activity.agentSessionId], + ["Sandbox Session", activity.sandboxSessionId], + ["Codex Thread", activity.threadId], + ].filter((entry): entry is [string, string] => Boolean(entry[1])); + if (!details.length) return null; + return ( +
+ {details.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + function loadSkillLabel(name: string, args: unknown): string | undefined { if (name !== "load_skill" || args == null || typeof args !== "object" || Array.isArray(args)) { return undefined; @@ -624,7 +664,9 @@ function ToolBlock({ status, defaultOpen = false, retrying = false, + codexActivity, onBranchSelect, + onAction, }: { name: string; args?: unknown; @@ -633,7 +675,9 @@ function ToolBlock({ status?: "running" | "completed" | "failed"; defaultOpen?: boolean; retrying?: boolean; + codexActivity?: Extract["codexActivity"]; onBranchSelect?: (branch: BranchCompareBranch) => void; + onAction: BlocksProps["onAction"]; }) { const inferredCreateAgentFailure = name === "create_agents" && done @@ -647,7 +691,7 @@ function ToolBlock({ const builtinTool = getBuiltinToolDefinition(name); const DetailRenderer = builtinTool?.detailRenderer; const hideHeader = builtinTool?.hideHeader === true; - const shouldDefaultOpen = hideHeader || defaultOpen || Boolean(DetailRenderer); + const shouldDefaultOpen = hideHeader || defaultOpen || Boolean(DetailRenderer) || Boolean(codexActivity); const [open, setOpen] = useState(shouldDefaultOpen); const touched = useRef(false); useEffect(() => { @@ -709,6 +753,34 @@ function ToolBlock({ ) : null}
+ {codexActivity ? ( +
+
+ + + Codex Sandbox + + {codexActivity.title} +
+ +
+ {codexActivity.items.length > 0 ? ( + item.block)} + streaming={!done} + onAction={onAction} + /> + ) : ( + + 正在等待 Codex 输出 + + )} +
+
+ ) : null} {DetailRenderer ? ( ); } diff --git a/frontend/src/ui/CodeEditor.tsx b/frontend/src/ui/CodeEditor.tsx index 5f835db8d..5d1c4cf25 100644 --- a/frontend/src/ui/CodeEditor.tsx +++ b/frontend/src/ui/CodeEditor.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import type { Extension } from "@codemirror/state"; import { StreamLanguage } from "@codemirror/language"; +import { lineNumbers } from "@codemirror/view"; import { javascript } from "@codemirror/lang-javascript"; import { json } from "@codemirror/lang-json"; import { markdown } from "@codemirror/lang-markdown"; @@ -15,6 +16,10 @@ interface CodeEditorProps { onChange: (value: string) => void; readOnly?: boolean; theme?: CodeWorkspaceTheme; + lineNumberStart?: number; + height?: string; + minHeight?: string; + maxHeight?: string; } export type CodeWorkspaceTheme = "light" | "dark"; @@ -50,19 +55,33 @@ export default function CodeEditor({ onChange, readOnly = false, theme = "light", + lineNumberStart = 1, + height = "100%", + minHeight, + maxHeight, }: CodeEditorProps) { - const extensions = useMemo(() => languageFor(path), [path]); + const extensions = useMemo( + () => [ + ...languageFor(path), + ...(lineNumberStart === 1 + ? [] + : [lineNumbers({ formatNumber: (lineNumber) => String(lineNumber + lineNumberStart - 1) })]), + ], + [lineNumberStart, path], + ); return ( legend { - margin: 0 0 8px 4px; - padding: 0; + grid-template-columns: var(--environment-label-width) minmax(0, 1fr); + align-items: center; + gap: 7px 20px; color: hsl(var(--foreground)); font-size: 14px; font-weight: 500; - line-height: 1.4; -} - -.environment-creation-options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; } - -.environment-creation-options > * { - min-width: 0; - min-height: 76px; - box-sizing: border-box; +.environment-field > span:first-child { + min-height: 40px; display: flex; align-items: center; - gap: 12px; - padding: 12px 14px; - border: 1px solid hsl(var(--border)); - border-radius: 10px; - background: hsl(var(--panel)); - transition: border-color 160ms ease, background-color 160ms ease; + white-space: nowrap; } - -.environment-creation-options > *:hover { - background: hsl(var(--muted) / 0.45); +.environment-field > small { + grid-column: 2; + align-self: start; + color: hsl(var(--muted-foreground)); + font-size: 11.5px; + font-weight: 400; + line-height: 1.45; +} +.environment-text-input, +.environment-description-input { width: 100%; } +.environment-description-input { min-height: 76px; } +.environment-field:has(.environment-description-input) > span:first-child { + align-items: flex-start; + padding-top: 0; } -.environment-creation-options > *.is-selected { - border-color: hsl(var(--foreground) / 0.3); - background: hsl(var(--muted) / 0.55); +.environment-field:has(.environment-description-input) { + align-items: start; } -.environment-creation-option__icon { - width: 36px; - height: 36px; - display: inline-grid; - flex: 0 0 36px; - place-items: center; - border: 1px solid hsl(var(--border)); - border-radius: 8px; - background: hsl(var(--background)); - color: hsl(var(--muted-foreground)); +.environment-required-mark { + margin-left: 2px; + color: hsl(var(--destructive)); + font: inherit; } -.environment-creation-options > *.is-selected .environment-creation-option__icon { - color: hsl(var(--foreground)); +.environment-creation-method { + margin: 24px 0 0; } -.environment-creation-option__icon svg { - width: 19px; - height: 19px; +.environment-select-trigger { + width: 100%; + font-size: 13px; + font-weight: 400; } -.environment-creation-option__copy { - min-width: 0; - display: grid; - gap: 3px; - text-align: left; +.environment-select-trigger :is(button, span, div) { + font-size: inherit; + font-weight: inherit; } -.environment-creation-option__copy strong { +.environment-select-option > div > div:first-child { color: hsl(var(--foreground)); font-size: 13px; - font-weight: 600; + font-weight: 500; line-height: 1.4; } -.environment-creation-option__copy > span { +.environment-select-option > div > div + div { color: hsl(var(--muted-foreground)); - font-size: 12px; + font-size: 11.5px; font-weight: 400; line-height: 1.45; } -.environment-tabs { - width: fit-content; +.environment-configuration { margin-top: 24px; + padding-top: 20px; + border-top: 1px dashed hsl(var(--border)); } -.environment-configuration, -.environment-dockerfile { margin-top: 24px; } - .environment-section { min-width: 0; } .environment-section + .environment-section { margin-top: 22px; } -.environment-section > h2, -.environment-dockerfile__header h2 { +.environment-section > h2 { margin: 0 0 8px 4px; color: hsl(var(--foreground)); font-size: 14px; @@ -193,85 +164,77 @@ line-height: 1.4; } -.environment-base-options, -.environment-language-options, .environment-option-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 24px; } -.environment-base-options > * { - min-width: 0; - min-height: 68px; - box-sizing: border-box; - display: flex; - align-items: center; - padding: 8px 10px; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; -} - -.environment-base-copy { - min-width: 0; +.environment-skill-grid { display: grid; - gap: 2px; - text-align: left; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 24px; } -.environment-base-copy strong { - color: hsl(var(--foreground)); - font-size: 13px; - font-weight: 600; - line-height: 1.4; +.environment-skill-grid > .cw-skillspane, +.environment-skill-grid .cw-skill-selected, +.environment-skill-grid .cw-selected-skill-list { + display: contents; } -.environment-base-copy span { - color: hsl(var(--muted-foreground)); - font-size: 12px; - font-weight: 400; - line-height: 1.4; +.environment-skill-grid .cw-skill-add { + min-height: 54px; + justify-content: flex-start; + gap: 10px; + padding: 4px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + text-align: left; } -.environment-os-version-options { - width: min(100%, 420px); - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2px 16px; - margin: 8px 0 0 30px; +.environment-skill-grid .cw-skill-add-icon, +.environment-skill-grid .cw-selected-skill-icon { + width: 38px; + height: 38px; + flex: 0 0 38px; + box-sizing: border-box; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--panel)); + color: hsl(var(--foreground) / 0.78); } -.environment-os-version-options > * { - min-width: 0; - min-height: 44px; - box-sizing: border-box; - display: flex; - align-items: center; - padding: 3px 10px; +.environment-skill-grid .cw-skill-add-icon svg, +.environment-skill-grid .cw-selected-skill-icon svg { + width: 18px; + height: 18px; } -.environment-language-options > * { - min-width: 0; +.environment-skill-grid .cw-selected-skill-row { min-height: 54px; - height: 54px; - box-sizing: border-box; - display: flex; - align-items: center; - padding: 4px 10px; - border: 1px solid transparent; + gap: 10px; + padding: 4px; + border-color: transparent; border-radius: 8px; - background: transparent; + background: hsl(var(--muted) / 0.72); } -.environment-language-copy { - min-width: 0; - display: flex; - align-items: center; - color: hsl(var(--foreground)); + +.environment-skill-grid .cw-selected-skill-name { font-size: 13px; font-weight: 500; - line-height: 1.4; - text-align: left; +} + +.environment-skill-grid .cw-selected-skill-list { + max-height: none; + padding-right: 0; + overflow: visible; +} + +.environment-form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; } .environment-package-fallback { @@ -287,22 +250,6 @@ line-height: 1; } -.environment-dockerfile__header { - min-width: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 10px; -} -.environment-dockerfile__header h2 { margin-bottom: 2px; margin-left: 0; } -.environment-dockerfile__header p { - margin: 0; - color: hsl(var(--muted-foreground)); - font-size: 12px; - line-height: 1.5; -} - .environment-dockerfile__editor { width: 100%; min-height: 520px; @@ -321,155 +268,188 @@ .environment-upload { min-width: 0; margin-top: 24px; + padding-top: 20px; + border-top: 1px dashed hsl(var(--border)); +} + +.environment-dockerfile-settings { + display: grid; + gap: 16px; +} + +.environment-upload__error { + margin: 8px 0 0; + color: hsl(var(--destructive)); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.environment-upload__error--below { + margin-top: 8px; } -.environment-upload__header { +.environment-upload__preview { min-width: 0; + margin-top: 20px; +} + +.environment-upload__preview > div:first-child { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 16px; - margin-bottom: 10px; + gap: 12px; + margin-bottom: 8px; } -.environment-upload__header > div { min-width: 0; } -.environment-upload__header h2 { - margin: 0 0 3px; +.environment-upload__preview h3 { + margin: 0; color: hsl(var(--foreground)); font-size: 14px; font-weight: 500; line-height: 1.4; } -.environment-upload__header p { - margin: 0; +.environment-upload__size { color: hsl(var(--muted-foreground)); - font-size: 12px; - line-height: 1.5; + font-size: 11px; + line-height: 1.4; + font-variant-numeric: tabular-nums; } -.environment-upload-dropzone { - position: relative; - min-height: 112px; - box-sizing: border-box; +.environment-upload__actions { + min-width: 0; display: flex; align-items: center; - justify-content: center; - gap: 12px; - padding: 20px; - border: 1px dashed hsl(var(--border)); - border-radius: 10px; - background: hsl(var(--panel)); - color: hsl(var(--foreground)); - transition: border-color 160ms ease, background-color 160ms ease; -} - -.environment-upload-dropzone:hover, -.environment-upload-dropzone.is-dragging { - border-color: hsl(var(--foreground) / 0.4); - background: hsl(var(--muted) / 0.5); + justify-content: flex-end; + gap: 8px; } -.environment-upload-dropzone.is-ready { - border-style: solid; +.environment-upload__actions > .environment-upload__size { + margin-right: 2px; + white-space: nowrap; } -.environment-upload-dropzone:focus-within { - outline: 2px solid hsl(var(--ring) / 0.55); - outline-offset: 2px; +.environment-upload__action { + font-size: 12px; } -.environment-upload-dropzone > input { +.environment-upload__file-input { position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - opacity: 0; - cursor: pointer; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; } -.environment-upload-dropzone > input:disabled { cursor: wait; } +.environment-dockerfile-editor { + --environment-dockerfile-gutter-width: 48px; + --environment-dockerfile-editor-max-height: 404px; -.environment-upload-dropzone__icon { - width: 36px; - height: 36px; - display: inline-grid; - flex: 0 0 36px; - place-items: center; + min-width: 0; + overflow: hidden; border: 1px solid hsl(var(--border)); border-radius: 8px; background: hsl(var(--background)); - color: hsl(var(--muted-foreground)); } -.environment-upload-dropzone__icon svg { - width: 19px; - height: 19px; +.environment-dockerfile-editor.is-invalid { + border-color: hsl(var(--destructive) / 0.72); } -.environment-upload-dropzone__copy { +.environment-dockerfile-from { min-width: 0; + min-height: 28px; display: grid; - gap: 3px; + grid-template-columns: var(--environment-dockerfile-gutter-width) minmax(0, 1fr); + align-items: stretch; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.72); + color: hsl(var(--foreground)); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.65; +} + +.environment-dockerfile-from__line { + display: grid; + place-items: center end; + padding: 5px 11px 3px 5px; + color: hsl(var(--muted-foreground)); + user-select: none; +} + +.environment-dockerfile-from code { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px 3px 5px; + overflow: hidden; + border-left: 1px solid hsl(var(--border)); + font: inherit; } -.environment-upload-dropzone__copy strong, -.environment-upload-dropzone__copy span { +.environment-dockerfile-from code > span:last-child { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.environment-upload-dropzone__copy strong { - font-size: 13px; +.environment-dockerfile-from__keyword { + flex: 0 0 auto; + color: hsl(347 62% 47%); font-weight: 600; - line-height: 1.4; } -.environment-upload-dropzone__copy span { - color: hsl(var(--muted-foreground)); - font-size: 12px; - line-height: 1.45; +.environment-dockerfile-editor.has-fixed-base { + --environment-dockerfile-editor-max-height: 384px; } -.environment-upload__error { - margin: 8px 0 0; - color: hsl(var(--destructive)); - font-size: 12px; - line-height: 1.5; - overflow-wrap: anywhere; +.environment-upload__editor { + min-height: 0; + max-height: var(--environment-dockerfile-editor-max-height); + overflow: hidden; + border: 0; } -.environment-upload__preview { - min-width: 0; - margin-top: 20px; +.environment-upload__editor > div, +.environment-upload__editor .cm-editor, +.environment-upload__editor .cm-scroller { + min-height: 28px; + max-height: var(--environment-dockerfile-editor-max-height); } -.environment-upload__preview > div:first-child { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; +.environment-upload__editor .cm-gutters { + width: var(--environment-dockerfile-gutter-width); + min-width: var(--environment-dockerfile-gutter-width); + box-sizing: border-box; } -.environment-upload__preview h3 { - margin: 0; - color: hsl(var(--foreground)); - font-size: 14px; - font-weight: 500; - line-height: 1.4; +.environment-upload__editor .cm-lineNumbers { + width: 100%; } -.environment-upload__preview span { - color: hsl(var(--muted-foreground)); - font-size: 11px; - line-height: 1.4; - font-variant-numeric: tabular-nums; +.environment-upload__editor .cm-lineNumbers .cm-gutterElement { + width: 100%; + min-width: 0; + box-sizing: border-box; + padding-right: 10px; } -.environment-upload__editor { min-height: 440px; } +.environment-upload__editor .cm-foldGutter { + display: none !important; +} + +.environment-upload__editor .cm-scroller { + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.65; +} .environment-source-workflow, .environment-source-section { @@ -487,8 +467,11 @@ .environment-source-workflow > .environment-source-section + .environment-source-section { margin-top: 24px; - padding-top: 22px; - border-top: 1px solid hsl(var(--border) / 0.72); +} + +.environment-source-section { + padding-top: 20px; + border-top: 1px dashed hsl(var(--border)); } .environment-source-section:has(.pp-deployment-select-trigger[aria-expanded="true"]) { @@ -496,20 +479,6 @@ z-index: 70; } -.environment-source-section__header { - min-width: 0; - margin-bottom: 12px; -} - -.environment-source-section__header h2 { - margin: 0 0 3px; - color: hsl(var(--foreground)); - font-size: 14px; - font-weight: 500; - line-height: 1.4; -} - -.environment-source-section__header p, .environment-source-note { margin: 0; color: hsl(var(--muted-foreground)); @@ -517,35 +486,6 @@ line-height: 1.5; } -.environment-source-fields { - min-width: 0; - display: grid; - align-items: end; - gap: 10px; -} - -.environment-source-fields--git { - grid-template-columns: minmax(0, 1fr) minmax(220px, 0.42fr); -} - -.environment-source-field { - min-width: 0; - display: grid; - gap: 6px; -} - -.environment-source-field__label { - color: hsl(var(--muted-foreground)); - font-size: 12px; - font-weight: 500; - line-height: 1.4; -} - -.environment-source-field .environment-text-input, -.environment-source-field > div:not(.environment-region-control) { - width: 100%; -} - .environment-source-action { min-height: 36px; display: flex; @@ -558,12 +498,15 @@ .environment-inspection-status { min-height: 18px; - margin-top: 8px; color: hsl(var(--muted-foreground)); font-size: 11.5px; line-height: 1.5; } +.environment-form-feedback { + margin: 0 0 0 calc(var(--environment-label-width) + 20px); +} + .environment-source-error { display: flex; align-items: flex-start; @@ -601,31 +544,56 @@ } .environment-dockerfile-picker { - max-width: 620px; - margin-top: 10px; + margin-top: 16px; } -.environment-repository-mode { - width: min(100%, 420px); - margin-bottom: 16px; +.environment-repository-fields.pp-resource-fields-three { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + margin-top: 0; } -.environment-region-control { - width: fit-content; - max-width: 100%; +.environment-repository-fields .pp-resource-field { + display: grid; + grid-template-columns: var(--environment-label-width) minmax(0, 1fr); + align-items: center; + gap: 7px 20px; + color: hsl(var(--foreground)); + font-size: 14px; + font-weight: 500; } -.environment-repository-fields { - margin-top: 14px; +.environment-repository-fields .pp-resource-field > span:first-child { + min-height: 40px; + display: flex; + align-items: center; + white-space: nowrap; +} + +.environment-form .pp-deployment-select-trigger, +.environment-form .pp-deployment-select-name, +.environment-form .pp-resource-field > input { + font-size: 13px; + font-weight: 400; +} + +.environment-form .pp-deployment-select-copy small { + font-size: 11.5px; +} + +.environment-repository-fields .pp-resource-field > span:first-child::after { + content: "*"; + margin-left: 2px; + color: hsl(var(--destructive)); } .environment-source-note { - margin-top: 10px; + margin-top: 0; } .environment-image-reference { - max-width: 620px; - margin-top: 16px; + max-width: none; + margin-top: 0; } .environment-image-reference small { @@ -985,34 +953,49 @@ } @media (max-width: 760px) { - .environment-source-fields--git { - grid-template-columns: minmax(0, 1fr); - align-items: stretch; - } - .environment-source-action { min-height: auto; } - - .environment-card .resource-card__metadata > div:last-child { - display: none; - } } @media (max-width: 560px) { - .environment-creation-options, - .environment-base-options, - .environment-language-options, - .environment-option-grid { grid-template-columns: minmax(0, 1fr); } - .environment-os-version-options { - width: auto; + .environment-field { + grid-template-columns: minmax(0, 1fr); + gap: 7px; + } + .environment-field > span:first-child { + min-height: 0; + white-space: normal; + } + .environment-field > small { + grid-column: 1; + } + .environment-form-feedback { + margin-left: 0; + } + .environment-repository-fields .pp-resource-field { grid-template-columns: minmax(0, 1fr); - margin-left: 30px; + gap: 7px; + } + .environment-repository-fields .pp-resource-field > span:first-child { + min-height: 0; + white-space: normal; + } + .environment-field:has(.environment-description-input) > span:first-child { + padding-top: 0; + } + .environment-form-grid, + .environment-option-grid, + .environment-skill-grid { grid-template-columns: minmax(0, 1fr); } + .environment-upload__preview > div:first-child { + align-items: flex-start; + } + .environment-upload__actions { + gap: 4px; + } + .environment-upload__actions > .environment-upload__size { + margin-right: 0; } - .environment-upload__header { flex-direction: column; } - .environment-upload-dropzone { justify-content: flex-start; min-height: 96px; padding: 16px; } - .environment-dockerfile__header { align-items: flex-start; flex-direction: column; } - .environment-dockerfile__editor { min-height: 440px; } .environment-build-dialog__backdrop { align-items: end; padding: 0; } .environment-build-dialog { width: 100%; @@ -1048,10 +1031,6 @@ .environment-clipboard-notice { align-items: flex-start; flex-direction: column; } } -@media (prefers-reduced-motion: reduce) { - .environment-creation-options > *, - .environment-upload-dropzone { transition: none; } -} .environment-card .library-resource-card__auxiliary-action { border-color: transparent; background: transparent; diff --git a/frontend/src/ui/EnvironmentCenter.tsx b/frontend/src/ui/EnvironmentCenter.tsx index cefcb044e..86656df5a 100644 --- a/frontend/src/ui/EnvironmentCenter.tsx +++ b/frontend/src/ui/EnvironmentCenter.tsx @@ -6,21 +6,18 @@ import { useMemo, useRef, useState, - type ChangeEvent, - type DragEvent, type FormEvent, type RefObject, type SVGProps, } from "react"; import { createPortal } from "react-dom"; -import { ExternalLink, FileUp, SlidersHorizontal, X } from "lucide-react"; +import { ExternalLink, X } from "lucide-react"; import { Badge } from "@openai/apps-sdk-ui/components/Badge"; import { Button } from "@openai/apps-sdk-ui/components/Button"; import { EmptyMessage } from "@openai/apps-sdk-ui/components/EmptyMessage"; import { ArrowRotateCw, FileCode } from "@openai/apps-sdk-ui/components/Icon"; import { Input } from "@openai/apps-sdk-ui/components/Input"; -import { RadioGroup } from "@openai/apps-sdk-ui/components/RadioGroup"; -import { SegmentedControl } from "@openai/apps-sdk-ui/components/SegmentedControl"; +import { Select, type Option } from "@openai/apps-sdk-ui/components/Select"; import { Textarea } from "@openai/apps-sdk-ui/components/Textarea"; import feishuLogo from "../assets/feishu-logo.svg"; import pandocLogo from "../assets/pandoc-logo.svg"; @@ -51,6 +48,7 @@ import { StudioConfirmDialog } from "./StudioConfirmDialog"; import { StudioBuildProgress } from "./StudioBuildProgress"; import { StudioPackageOption } from "./StudioPackageOption"; import CodeEditor from "./CodeEditor"; +import { formatRelativeTimeLabel } from "./relativeTime"; import { buildEnvironment, createEnvironment, @@ -76,6 +74,8 @@ import { } from "../adk/client"; import { buildEnvironmentDockerfile, + AIO_BASE_IMAGE, + CODEX_SANDBOX_BASE_IMAGES, EMPTY_ENVIRONMENT_DRAFT, ENVIRONMENT_BASE_ENVIRONMENTS, ENVIRONMENT_CATEGORIES, @@ -94,8 +94,12 @@ import { import { TextShimmer } from "./text-shimmer/TextShimmer"; import { SkillSourcePicker } from "./SkillSourcePicker"; import { + composeDockerfile, + dockerfileBaseImage, + dockerfileBody, dockerfileByteSize, readDockerfileUpload, + validateDockerfileBody, validateDockerfileUpload, } from "./environmentDockerfileUpload"; import { formatEnvironmentManifest } from "./environmentManifest"; @@ -113,9 +117,62 @@ type EnvironmentView = | { kind: "list" } | { kind: "editor"; environmentId: string | null }; -type EnvironmentEditorTab = "configuration" | "dockerfile"; type EnvironmentCreationMethod = "custom" | "dockerfile" | "git" | "image"; type GitRepositoryMode = "managed" | "existing"; +type DockerfilePresetEnvironment = "none" | "aio-sandbox" | "codex-sandbox"; + +const ENVIRONMENT_CREATION_OPTIONS: Option[] = [ + { value: "custom", label: "自定义配置", description: "通过表单选择基础环境、Python、工具和技能" }, + { value: "dockerfile", label: "自定义 Dockerfile", description: "上传或直接编辑 Dockerfile" }, + { value: "git", label: "从代码仓库构建", description: "探查公开仓库并通过 CodePipeline 构建" }, + { value: "image", label: "使用已有镜像", description: "绑定由外部流水线交付的 CR 镜像" }, +]; + +const ENVIRONMENT_BASE_OPTIONS: Option[] = ENVIRONMENT_BASE_ENVIRONMENTS.map((item) => ({ + value: item.id, + label: item.label, + description: item.description, +})); + +const DOCKERFILE_PRESET_ENVIRONMENT_OPTIONS: Option[] = [ + { + value: "none", + label: "无", + description: "自行填写 Dockerfile 基础镜像", + }, + { + value: "aio-sandbox", + label: "AIO Sandbox", + description: "内置 Sandbox Shell 与常用运行时", + }, + { + value: "codex-sandbox", + label: "Codex Sandbox", + description: "内置 Codex CLI、浏览器与代码执行环境", + }, +]; + +function dockerfilePresetEnvironmentFromContent(content: string): DockerfilePresetEnvironment { + const baseImage = dockerfileBaseImage(content, ""); + if (baseImage === AIO_BASE_IMAGE) return "aio-sandbox"; + if (baseImage.includes("/codexenv:")) return "codex-sandbox"; + return "none"; +} + +const ENVIRONMENT_OS_OPTIONS: Option[] = ENVIRONMENT_OPERATING_SYSTEMS.map((item) => ({ + value: item.id, + label: item.label, +})); + +const ENVIRONMENT_LANGUAGE_OPTIONS: Option[] = ENVIRONMENT_LANGUAGES.map((item) => ({ + value: item.id, + label: item.label, +})); + +const ENVIRONMENT_REPOSITORY_MODE_OPTIONS: Option[] = [ + { value: "managed", label: "Studio 默认镜像仓库" }, + { value: "existing", label: "已有镜像仓库" }, +]; const MAX_ENVIRONMENT_SHARE_CODES = 20; const promptedClipboardShareTexts = new Set(); @@ -156,25 +213,8 @@ function AddIcon(props: SVGProps) { ); } -function GitRepositoryIcon(props: SVGProps) { - return ( - - ); -} - -function ContainerImageIcon(props: SVGProps) { - return ( - - ); +function RequiredMark() { + return ; } function ImportEnvironmentIcon(props: SVGProps) { @@ -246,7 +286,10 @@ function EnvironmentPackageIcon({ option }: { option: EnvironmentOption }) { return ; } -function environmentDraft(environment?: StudioEnvironment): EnvironmentDraft { +function environmentDraft( + environment: StudioEnvironment | undefined, + cloudProvider: CloudProvider, +): EnvironmentDraft { if (!environment) { return { ...EMPTY_ENVIRONMENT_DRAFT, @@ -263,7 +306,7 @@ function environmentDraft(environment?: StudioEnvironment): EnvironmentDraft { optionIds: [...environment.optionIds], selectedSkills: [...environment.selectedSkills], dockerfile: - environment.dockerfile === buildEnvironmentDockerfile(environment) + environment.dockerfile === buildEnvironmentDockerfile(environment, cloudProvider) ? undefined : environment.dockerfile, gitSource: environment.gitSource, @@ -302,13 +345,15 @@ function environmentStatus(environment: StudioEnvironment): { } function environmentUpdatedAt(value: string): string { + return formatRelativeTimeLabel(value); +} + +function environmentUpdatedAtTitle(value: string): string { const timestamp = Date.parse(value); if (Number.isNaN(timestamp)) return value; return new Intl.DateTimeFormat("zh-CN", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", + dateStyle: "medium", + timeStyle: "medium", }).format(timestamp); } @@ -668,24 +713,27 @@ function EnvironmentRegionSelector({ disabled: boolean; onChange: (region: CloudRegion) => void; }) { - const options = cloudRegionOptions(cloudProvider); + const options: Option[] = cloudRegionOptions(cloudProvider).map((option) => ({ + value: option.value, + label: option.label, + })); return ( -
- Region - + 区域 + -
-
+
{inspecting ? 正在拉取仓库并查找 Dockerfile : null} {inspectError ? (
@@ -862,8 +907,8 @@ function GitRepositoryFields({ ) : null}
{dockerfiles.length > 0 ? ( -
+ -
-

执行环境

-
+
+

技能

+
undefined} + selected={veadkSelected} + disabled={saving} + onChange={setVeadkSelected} icon={} /> + setDraft((current) => ({ ...current, selectedSkills }))} + cloudProvider={cloudProvider} + disabled={saving} + addLabel="添加环境技能" + showSelectedCount={false} + />
-
-

技能

- setDraft((current) => ({ ...current, selectedSkills }))} - cloudProvider={cloudProvider} - disabled={saving} - addLabel="添加环境技能" - /> -
- {ENVIRONMENT_CATEGORIES.map((category) => (

{category.label}

@@ -1879,96 +1868,96 @@ function EnvironmentEditor({
))} -
- ) : ( -
-
-
-

Dockerfile

-

可直接编辑;配置页中的软件变更不会覆盖自定义内容。

-
- {draft.dockerfile !== undefined ? ( - - ) : null} -
-