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..575018b05 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: @@ -438,10 +443,16 @@ class EnvironmentManifestStatus(BaseModel): updated_at: datetime = Field(alias="updatedAt") +EnvironmentManifestApiVersion = Literal[ + "agentkit.studio/v3", + "agentkit.studio/v1alpha1", +] + + class EnvironmentManifest(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) - api_version: Literal["agentkit.studio/v1alpha1"] = Field(alias="apiVersion") + api_version: EnvironmentManifestApiVersion = Field(alias="apiVersion") kind: Literal["Environment"] = "Environment" metadata: EnvironmentManifestMetadata spec: EnvironmentManifestSpec diff --git a/frontend/server/environments/repository.py b/frontend/server/environments/repository.py index 4f1e934b2..25ff14fc3 100644 --- a/frontend/server/environments/repository.py +++ b/frontend/server/environments/repository.py @@ -31,7 +31,8 @@ _VERSION_RE = re.compile(r"[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}") _MAX_JSON_BYTES = 256 * 1024 _MAX_LOG_BYTES = 512 * 1024 -_CURRENT_STORAGE_VERSION = "v2" +_CURRENT_STORAGE_VERSION = "v3" +_PREVIOUS_STORAGE_VERSION = "v2" _LEGACY_RECORD_FIELDS = ( "name", "description", @@ -81,6 +82,10 @@ def __init__( self._client_factory = client_factory legacy_root_prefix = root_prefix.strip("/") self._legacy_prefix = f"{legacy_root_prefix}/environments" + self._previous_prefix = ( + f"{_replace_storage_version(legacy_root_prefix, _PREVIOUS_STORAGE_VERSION)}" + "/environments" + ) self._prefix = ( f"{_replace_storage_version(legacy_root_prefix, _CURRENT_STORAGE_VERSION)}" "/environments" @@ -207,10 +212,11 @@ def context_key(self, owner_id: str, environment_id: str, version_id: str) -> st def _list(self, owner_id: str) -> list[EnvironmentRecord]: client = self._client_factory() - records_by_id: dict[str, EnvironmentRecord] = {} - for prefix, legacy in ( - (self._prefix, False), - (self._legacy_prefix, True), + records_by_id: dict[str, dict[str, tuple[EnvironmentRecord, bytes]]] = {} + for prefix, storage_version in ( + (self._prefix, _CURRENT_STORAGE_VERSION), + (self._previous_prefix, _PREVIOUS_STORAGE_VERSION), + (self._legacy_prefix, "v1"), ): owner_prefix = f"{self._owner_prefix_for(prefix, owner_id)}/" for key in self._list_keys(client, owner_prefix): @@ -220,11 +226,23 @@ def _list(self, owner_id: str) -> list[EnvironmentRecord]: record = EnvironmentRecord.model_validate_json(content) if record.owner_id != owner_id: continue - if legacy: - self._repair_legacy_record(client, record, content) - records_by_id.setdefault(record.id, record) + records_by_id.setdefault(record.id, {})[storage_version] = ( + record, + content, + ) + records: list[EnvironmentRecord] = [] + for candidates in records_by_id.values(): + record, content, storage_version = self._select_record(candidates) + self._reconcile_record_copies( + client, + record, + content, + storage_version=storage_version, + candidates=candidates, + ) + records.append(record) return sorted( - records_by_id.values(), + records, key=lambda item: (item.updated_at, item.id), reverse=True, ) @@ -232,18 +250,28 @@ def _list(self, owner_id: str) -> list[EnvironmentRecord]: def _get(self, owner_id: str, environment_id: str) -> EnvironmentRecord: self._validate_environment_id(environment_id) client = self._client_factory() - content, legacy = self._read_current_or_legacy( + candidates = self._read_versioned_candidates( client, self._summary_key(owner_id, environment_id), + self._previous_summary_key(owner_id, environment_id), self._legacy_summary_key(owner_id, environment_id), _MAX_JSON_BYTES, not_found_message="环境不存在或已被删除。", ) - record = EnvironmentRecord.model_validate_json(content) + records = { + storage_version: (EnvironmentRecord.model_validate_json(content), content) + for content, storage_version in candidates + } + record, content, storage_version = self._select_record(records) if record.id != environment_id or record.owner_id != owner_id: raise EnvironmentNotFound("环境不存在或已被删除。") - if legacy: - self._repair_legacy_record(client, record, content) + self._reconcile_record_copies( + client, + record, + content, + storage_version=storage_version, + candidates=records, + ) return record def _create(self, record: EnvironmentRecord) -> EnvironmentRecord: @@ -255,12 +283,9 @@ def _create(self, record: EnvironmentRecord) -> EnvironmentRecord: else: raise EnvironmentConflict("环境 ID 已存在。") try: - self._put_json( - self._client_factory(), - self._summary_key(record.owner_id, record.id), - record, - forbid_overwrite=True, - ) + client = self._client_factory() + for key in self._summary_write_keys(record): + self._put_json(client, key, record, forbid_overwrite=True) except Exception as error: if _status_code(error) in {409, 412}: raise EnvironmentConflict("环境 ID 已存在。") from error @@ -271,11 +296,9 @@ def _update(self, record: EnvironmentRecord) -> EnvironmentRecord: current = self._get(record.owner_id, record.id) if current.owner_id != record.owner_id: raise EnvironmentNotFound("环境不存在或已被删除。") - self._put_json( - self._client_factory(), - self._summary_key(record.owner_id, record.id), - record, - ) + client = self._client_factory() + for key in self._summary_write_keys(record): + self._put_json(client, key, record) return record def _delete(self, owner_id: str, environment_id: str) -> None: @@ -283,6 +306,7 @@ def _delete(self, owner_id: str, environment_id: str) -> None: client = self._client_factory() for prefix in ( self._environment_prefix(owner_id, environment_id), + self._previous_environment_prefix(owner_id, environment_id), self._legacy_environment_prefix(owner_id, environment_id), ): for key in self._list_keys(client, f"{prefix}/"): @@ -299,33 +323,32 @@ def _create_version( ) -> EnvironmentRecord: self._validate_version_id(build.version_id) client = self._client_factory() - version_prefix = self._version_prefix( - record.owner_id, record.id, build.version_id - ) - self._put_json(client, f"{version_prefix}/config.json", record) - self._put_bytes( - client, f"{version_prefix}/Dockerfile", dockerfile.encode(), "text/plain" - ) - self._put_bytes( - client, f"{version_prefix}/context.tar.gz", context, "application/gzip" - ) manifest = skill_manifest or EnvironmentSkillManifest() - self._put_json(client, f"{version_prefix}/skills-manifest.json", manifest) - for relative_path, content in skill_files: + for version_prefix in self._version_write_prefixes(record, build.version_id): + self._put_json(client, f"{version_prefix}/config.json", record) self._put_bytes( client, - f"{version_prefix}/skills/{relative_path}", - content, + f"{version_prefix}/Dockerfile", + dockerfile.encode(), "text/plain", ) - self._put_json(client, f"{version_prefix}/build.json", build) - self._put_json( - client, - f"{self._environment_prefix(record.owner_id, record.id)}/latest.json", - build, - ) + self._put_bytes( + client, f"{version_prefix}/context.tar.gz", context, "application/gzip" + ) + self._put_json(client, f"{version_prefix}/skills-manifest.json", manifest) + for relative_path, content in skill_files: + self._put_bytes( + client, + f"{version_prefix}/skills/{relative_path}", + content, + "text/plain", + ) + self._put_json(client, f"{version_prefix}/build.json", build) + for environment_prefix in self._environment_write_prefixes(record): + self._put_json(client, f"{environment_prefix}/latest.json", build) updated = record.model_copy(update={"latest_version_id": build.version_id}) - self._put_json(client, self._summary_key(record.owner_id, record.id), updated) + for key in self._summary_write_keys(updated): + self._put_json(client, key, updated) return updated def _create_external_version( @@ -337,36 +360,29 @@ def _create_external_version( ) -> EnvironmentRecord: self._validate_version_id(build.version_id) client = self._client_factory() - version_prefix = self._version_prefix( - record.owner_id, record.id, build.version_id - ) - self._put_json(client, f"{version_prefix}/config.json", record) - self._put_json( - client, - f"{version_prefix}/skills-manifest.json", - skill_manifest or EnvironmentSkillManifest(), - ) - for relative_path, content in skill_files: + manifest = skill_manifest or EnvironmentSkillManifest() + for version_prefix in self._version_write_prefixes(record, build.version_id): + self._put_json(client, f"{version_prefix}/config.json", record) + self._put_json(client, f"{version_prefix}/skills-manifest.json", manifest) + for relative_path, content in skill_files: + self._put_bytes( + client, + f"{version_prefix}/skills/{relative_path}", + content, + "text/plain", + ) + self._put_json(client, f"{version_prefix}/build.json", build) self._put_bytes( client, - f"{version_prefix}/skills/{relative_path}", - content, - "text/plain", + f"{version_prefix}/image.json", + json.dumps({"image": build.image}, ensure_ascii=False).encode(), + "application/json", ) - self._put_json(client, f"{version_prefix}/build.json", build) - self._put_json( - client, - f"{self._environment_prefix(record.owner_id, record.id)}/latest.json", - build, - ) - self._put_bytes( - client, - f"{version_prefix}/image.json", - json.dumps({"image": build.image}, ensure_ascii=False).encode(), - "application/json", - ) + for environment_prefix in self._environment_write_prefixes(record): + self._put_json(client, f"{environment_prefix}/latest.json", build) updated = record.model_copy(update={"latest_version_id": build.version_id}) - self._put_json(client, self._summary_key(record.owner_id, record.id), updated) + for key in self._summary_write_keys(updated): + self._put_json(client, key, updated) return updated def _put_skill_asset( @@ -375,12 +391,17 @@ def _put_skill_asset( self._validate_environment_id(environment_id) if not re.fullmatch(r"[0-9a-f]{64}", artifact_id): raise ValueError("Invalid environment skill artifact id.") - self._put_bytes( - self._client_factory(), - f"{self._environment_prefix(owner_id, environment_id)}/skills/{artifact_id}.json", - content, - "application/json", - ) + client = self._client_factory() + for prefix in ( + self._environment_prefix(owner_id, environment_id), + self._previous_environment_prefix(owner_id, environment_id), + ): + self._put_bytes( + client, + f"{prefix}/skills/{artifact_id}.json", + content, + "application/json", + ) def _get_skill_asset( self, owner_id: str, environment_id: str, artifact_id: str @@ -388,9 +409,10 @@ def _get_skill_asset( self._validate_environment_id(environment_id) if not re.fullmatch(r"[0-9a-f]{64}", artifact_id): raise ValueError("Invalid environment skill artifact id.") - content, _ = self._read_current_or_legacy( + content, _ = self._read_versioned( self._client_factory(), f"{self._environment_prefix(owner_id, environment_id)}/skills/{artifact_id}.json", + f"{self._previous_environment_prefix(owner_id, environment_id)}/skills/{artifact_id}.json", f"{self._legacy_environment_prefix(owner_id, environment_id)}/skills/{artifact_id}.json", 2 * 1024 * 1024, not_found_message="环境技能不存在或已被删除。", @@ -404,9 +426,10 @@ def _get_skill_manifest( self._validate_version_id(version_id) client = self._client_factory() try: - content, _ = self._read_current_or_legacy( + content, _ = self._read_versioned( client, f"{self._version_prefix(owner_id, environment_id, version_id)}/skills-manifest.json", + f"{self._previous_version_prefix(owner_id, environment_id, version_id)}/skills-manifest.json", f"{self._legacy_version_prefix(owner_id, environment_id, version_id)}/skills-manifest.json", _MAX_JSON_BYTES, not_found_message="环境技能清单不存在。", @@ -421,9 +444,10 @@ def _get_version_config( self._validate_environment_id(environment_id) self._validate_version_id(version_id) client = self._client_factory() - content, legacy = self._read_current_or_legacy( + content, storage_version = self._read_versioned( client, f"{self._version_prefix(owner_id, environment_id, version_id)}/config.json", + f"{self._previous_version_prefix(owner_id, environment_id, version_id)}/config.json", f"{self._legacy_version_prefix(owner_id, environment_id, version_id)}/config.json", _MAX_JSON_BYTES, not_found_message="环境构建版本不存在。", @@ -431,8 +455,14 @@ def _get_version_config( record = EnvironmentRecord.model_validate_json(content) if record.id != environment_id or record.owner_id != owner_id: raise EnvironmentNotFound("环境构建版本不存在。") - if legacy: - self._repair_legacy_version_record(client, record, version_id, content) + if storage_version != _CURRENT_STORAGE_VERSION: + self._repair_older_version_record( + client, + record, + version_id, + content, + storage_version=storage_version, + ) return record def _get_version_skill_files( @@ -440,15 +470,18 @@ def _get_version_skill_files( ) -> list[tuple[str, bytes]]: self._validate_environment_id(environment_id) self._validate_version_id(version_id) - prefix = f"{self._version_prefix(owner_id, environment_id, version_id)}/skills/" client = self._client_factory() - keys = self._list_keys(client, prefix) - if not keys: - prefix = ( - f"{self._legacy_version_prefix(owner_id, environment_id, version_id)}" - "/skills/" - ) + prefix = "" + keys: list[str] = [] + for candidate in ( + self._version_prefix(owner_id, environment_id, version_id), + self._previous_version_prefix(owner_id, environment_id, version_id), + self._legacy_version_prefix(owner_id, environment_id, version_id), + ): + prefix = f"{candidate}/skills/" keys = self._list_keys(client, prefix) + if keys: + break files: list[tuple[str, bytes]] = [] total = 0 for key in keys: @@ -468,17 +501,52 @@ def _get_build( self._validate_environment_id(environment_id) self._validate_version_id(version_id) client = self._client_factory() - content, legacy = self._read_current_or_legacy( + candidates = self._read_versioned_candidates( client, f"{self._version_prefix(owner_id, environment_id, version_id)}/build.json", + f"{self._previous_version_prefix(owner_id, environment_id, version_id)}/build.json", f"{self._legacy_version_prefix(owner_id, environment_id, version_id)}/build.json", _MAX_JSON_BYTES, not_found_message="环境构建版本不存在。", ) - build = EnvironmentBuild.model_validate_json(content) + builds = { + storage_version: (EnvironmentBuild.model_validate_json(content), content) + for content, storage_version in candidates + } + modern_builds = { + storage_version: value + for storage_version, value in builds.items() + if storage_version in {_CURRENT_STORAGE_VERSION, _PREVIOUS_STORAGE_VERSION} + } + if modern_builds: + storage_version, (build, content) = max( + modern_builds.items(), + key=lambda item: ( + item[1][0].updated_at, + item[0] == _CURRENT_STORAGE_VERSION, + ), + ) + else: + storage_version = "v1" + build, content = builds[storage_version] if build.environment_id != environment_id or build.version_id != version_id: raise EnvironmentNotFound("环境构建版本不存在。") - if legacy: + if modern_builds: + record = self._get_version_config(owner_id, environment_id, version_id) + reconciled = False + for prefix in self._version_write_prefixes(record, version_id): + existing = builds.get( + _CURRENT_STORAGE_VERSION + if prefix.startswith(self._prefix + "/") + else _PREVIOUS_STORAGE_VERSION + ) + if existing is None or existing[0] != build: + self._put_json(client, f"{prefix}/build.json", build) + reconciled = True + if reconciled: + for prefix in self._environment_write_prefixes(record): + self._put_json(client, f"{prefix}/latest.json", build) + elif storage_version == "v1": self._repair_legacy_build(client, owner_id, build, content) return build @@ -488,28 +556,28 @@ def _update_build( build: EnvironmentBuild, log: str | None, ) -> EnvironmentBuild: - version_prefix = self._version_prefix( - owner_id, build.environment_id, build.version_id - ) client = self._client_factory() - self._put_json(client, f"{version_prefix}/build.json", build) - self._put_json( - client, - f"{self._environment_prefix(owner_id, build.environment_id)}/latest.json", - build, + record = self._get_version_config( + owner_id, + build.environment_id, + build.version_id, ) - if log is not None: - payload = log.encode("utf-8")[-_MAX_LOG_BYTES:] - self._put_bytes( - client, f"{version_prefix}/build.log", payload, "text/plain" - ) - if build.image: - self._put_bytes( - client, - f"{version_prefix}/image.json", - json.dumps({"image": build.image}, ensure_ascii=False).encode(), - "application/json", - ) + payload = log.encode("utf-8")[-_MAX_LOG_BYTES:] if log is not None else None + for version_prefix in self._version_write_prefixes(record, build.version_id): + self._put_json(client, f"{version_prefix}/build.json", build) + if payload is not None: + self._put_bytes( + client, f"{version_prefix}/build.log", payload, "text/plain" + ) + if build.image: + self._put_bytes( + client, + f"{version_prefix}/image.json", + json.dumps({"image": build.image}, ensure_ascii=False).encode(), + "application/json", + ) + for environment_prefix in self._environment_write_prefixes(record): + self._put_json(client, f"{environment_prefix}/latest.json", build) return build def _get_build_log( @@ -521,9 +589,10 @@ def _get_build_log( self._validate_environment_id(environment_id) self._validate_version_id(version_id) try: - content, _ = self._read_current_or_legacy( + content, _ = self._read_versioned( self._client_factory(), f"{self._version_prefix(owner_id, environment_id, version_id)}/build.log", + f"{self._previous_version_prefix(owner_id, environment_id, version_id)}/build.log", f"{self._legacy_version_prefix(owner_id, environment_id, version_id)}/build.log", _MAX_LOG_BYTES, not_found_message="环境构建日志不存在。", @@ -538,6 +607,9 @@ def _owner_prefix(self, owner_id: str) -> str: def _legacy_owner_prefix(self, owner_id: str) -> str: return self._owner_prefix_for(self._legacy_prefix, owner_id) + def _previous_owner_prefix(self, owner_id: str) -> str: + return self._owner_prefix_for(self._previous_prefix, owner_id) + @staticmethod def _owner_prefix_for(prefix: str, owner_id: str) -> str: owner = quote(owner_id.strip(), safe="") @@ -553,6 +625,10 @@ def _legacy_environment_prefix(self, owner_id: str, environment_id: str) -> str: self._validate_environment_id(environment_id) return f"{self._legacy_owner_prefix(owner_id)}/{environment_id}" + def _previous_environment_prefix(self, owner_id: str, environment_id: str) -> str: + self._validate_environment_id(environment_id) + return f"{self._previous_owner_prefix(owner_id)}/{environment_id}" + def _version_prefix( self, owner_id: str, environment_id: str, version_id: str ) -> str: @@ -568,6 +644,38 @@ def _legacy_version_prefix( f"/versions/{version_id}" ) + def _previous_version_prefix( + self, owner_id: str, environment_id: str, version_id: str + ) -> str: + self._validate_version_id(version_id) + return ( + f"{self._previous_environment_prefix(owner_id, environment_id)}" + f"/versions/{version_id}" + ) + + def _environment_write_prefixes(self, record: EnvironmentRecord) -> tuple[str, ...]: + current = self._environment_prefix(record.owner_id, record.id) + if record.base_environment == "codex-sandbox": + return (current,) + return (current, self._previous_environment_prefix(record.owner_id, record.id)) + + def _version_write_prefixes( + self, record: EnvironmentRecord, version_id: str + ) -> tuple[str, ...]: + current = self._version_prefix(record.owner_id, record.id, version_id) + if record.base_environment == "codex-sandbox": + return (current,) + return ( + current, + self._previous_version_prefix(record.owner_id, record.id, version_id), + ) + + def _summary_write_keys(self, record: EnvironmentRecord) -> tuple[str, ...]: + return tuple( + f"{prefix}/summary.json" + for prefix in self._environment_write_prefixes(record) + ) + def _summary_key(self, owner_id: str, environment_id: str) -> str: return f"{self._environment_prefix(owner_id, environment_id)}/summary.json" @@ -576,67 +684,187 @@ def _legacy_summary_key(self, owner_id: str, environment_id: str) -> str: f"{self._legacy_environment_prefix(owner_id, environment_id)}/summary.json" ) - def _read_current_or_legacy( + def _previous_summary_key(self, owner_id: str, environment_id: str) -> str: + return f"{self._previous_environment_prefix(owner_id, environment_id)}/summary.json" + + def _read_versioned( self, client: Any, current_key: str, + previous_key: str, legacy_key: str, limit: int, *, not_found_message: str, - ) -> tuple[bytes, bool]: + ) -> tuple[bytes, str]: + last_not_found: Exception | None = None + for key, storage_version in ( + (current_key, _CURRENT_STORAGE_VERSION), + (previous_key, _PREVIOUS_STORAGE_VERSION), + (legacy_key, "v1"), + ): + try: + return self._read_object(client, key, limit), storage_version + except Exception as error: + if _status_code(error) != 404: + raise + last_not_found = error + raise EnvironmentNotFound(not_found_message) from last_not_found + + def _read_versioned_candidates( + self, + client: Any, + current_key: str, + previous_key: str, + legacy_key: str, + limit: int, + *, + not_found_message: str, + ) -> list[tuple[bytes, str]]: + candidates: list[tuple[bytes, str]] = [] + last_not_found: Exception | None = None + for key, storage_version in ( + (current_key, _CURRENT_STORAGE_VERSION), + (previous_key, _PREVIOUS_STORAGE_VERSION), + ): + try: + candidates.append( + (self._read_object(client, key, limit), storage_version) + ) + except Exception as error: + if _status_code(error) != 404: + raise + last_not_found = error + if candidates: + return candidates try: - return self._read_object(client, current_key, limit), False - except Exception as current_error: - if _status_code(current_error) != 404: + return [(self._read_object(client, legacy_key, limit), "v1")] + except Exception as error: + if _status_code(error) != 404: raise - try: - return self._read_object(client, legacy_key, limit), True - except Exception as legacy_error: - if _status_code(legacy_error) == 404: - raise EnvironmentNotFound(not_found_message) from legacy_error - raise + last_not_found = error + raise EnvironmentNotFound(not_found_message) from last_not_found + + @staticmethod + def _select_record( + candidates: dict[str, tuple[EnvironmentRecord, bytes]], + ) -> tuple[EnvironmentRecord, bytes, str]: + modern = { + storage_version: value + for storage_version, value in candidates.items() + if storage_version in {_CURRENT_STORAGE_VERSION, _PREVIOUS_STORAGE_VERSION} + } + selected = modern or candidates + storage_version, (record, content) = max( + selected.items(), + key=lambda item: ( + item[1][0].updated_at, + item[0] == _CURRENT_STORAGE_VERSION, + item[0] == _PREVIOUS_STORAGE_VERSION, + ), + ) + return record, content, storage_version - def _repair_legacy_record( + def _reconcile_record_copies( self, client: Any, record: EnvironmentRecord, content: bytes, + *, + storage_version: str, + candidates: dict[str, tuple[EnvironmentRecord, bytes]], + ) -> None: + if storage_version == "v1": + self._repair_older_record( + client, + record, + content, + storage_version=storage_version, + ) + return + + current = candidates.get(_CURRENT_STORAGE_VERSION) + if current is None or current[0] != record: + self._put_json( + client, + self._summary_key(record.owner_id, record.id), + record, + ) + + previous_key = self._previous_summary_key(record.owner_id, record.id) + previous = candidates.get(_PREVIOUS_STORAGE_VERSION) + if record.base_environment == "codex-sandbox": + if previous is not None: + client.delete_object(bucket=self.bucket, key=previous_key) + return + if previous is None or previous[0] != record: + self._put_json(client, previous_key, record) + + def _repair_older_record( + self, + client: Any, + record: EnvironmentRecord, + content: bytes, + *, + storage_version: str, ) -> None: payload = _json_object(content) - if not (_CURRENT_ONLY_RECORD_FIELDS & payload.keys()): + if not _record_requires_newer_storage(payload, record, storage_version): return self._put_json_if_absent( client, self._summary_key(record.owner_id, record.id), record, ) - self._put_json( - client, - self._legacy_summary_key(record.owner_id, record.id), - _legacy_record_payload(record), - ) + if storage_version == _PREVIOUS_STORAGE_VERSION: + key = self._previous_summary_key(record.owner_id, record.id) + else: + key = self._legacy_summary_key(record.owner_id, record.id) + if storage_version == "v1": + if record.base_environment == "codex-sandbox": + client.delete_object(bucket=self.bucket, key=key) + return + self._put_json(client, key, _legacy_record_payload(record)) + self._put_json( + client, + self._previous_summary_key(record.owner_id, record.id), + record, + ) - def _repair_legacy_version_record( + def _repair_older_version_record( self, client: Any, record: EnvironmentRecord, version_id: str, content: bytes, + *, + storage_version: str, ) -> None: payload = _json_object(content) - if not (_CURRENT_ONLY_RECORD_FIELDS & payload.keys()): + if not _record_requires_newer_storage(payload, record, storage_version): return self._put_json_if_absent( client, f"{self._version_prefix(record.owner_id, record.id, version_id)}/config.json", record, ) - self._put_json( - client, - f"{self._legacy_version_prefix(record.owner_id, record.id, version_id)}/config.json", - _legacy_record_payload(record), - ) + if storage_version == _PREVIOUS_STORAGE_VERSION: + prefix = self._previous_version_prefix( + record.owner_id, record.id, version_id + ) + else: + prefix = self._legacy_version_prefix(record.owner_id, record.id, version_id) + key = f"{prefix}/config.json" + if storage_version == "v1": + if record.base_environment == "codex-sandbox": + client.delete_object(bucket=self.bucket, key=key) + return + self._put_json(client, key, _legacy_record_payload(record)) + self._put_json( + client, + f"{self._previous_version_prefix(record.owner_id, record.id, version_id)}/config.json", + record, + ) def _repair_legacy_build( self, @@ -789,6 +1017,18 @@ def _legacy_record_payload(record: EnvironmentRecord) -> dict[str, Any]: return {key: payload[key] for key in _LEGACY_RECORD_FIELDS} +def _record_requires_newer_storage( + payload: dict[str, Any], + record: EnvironmentRecord, + storage_version: str, +) -> bool: + if storage_version == "v1": + return record.base_environment == "codex-sandbox" or bool( + _CURRENT_ONLY_RECORD_FIELDS & payload.keys() + ) + return storage_version == _PREVIOUS_STORAGE_VERSION + + __all__ = [ "EnvironmentConflict", "EnvironmentNotFound", diff --git a/frontend/server/environments/routes.py b/frontend/server/environments/routes.py index 907c7e24c..738168ab6 100644 --- a/frontend/server/environments/routes.py +++ b/frontend/server/environments/routes.py @@ -45,6 +45,7 @@ def mount_environment_routes( service: EnvironmentService, identity_resolver: Callable[[Request], str], ) -> None: + @app.post("/web/v3/environment-repositories/inspect") @app.post("/web/environment-repositories/inspect") async def inspect_environment_repository( body: RepositoryInspectRequest, @@ -58,6 +59,7 @@ async def inspect_environment_repository( _raise_api_error(error, "探查 Git 仓库") raise + @app.get("/web/v3/environments") @app.get("/web/environments") async def list_environments(request: Request) -> dict[str, Any]: owner_id = identity_resolver(request) @@ -68,6 +70,7 @@ async def list_environments(request: Request) -> dict[str, Any]: raise return {"items": [_public(record) for record in records]} + @app.post("/web/v3/environments", status_code=201) @app.post("/web/environments", status_code=201) async def create_environment( body: EnvironmentInput, @@ -80,6 +83,7 @@ async def create_environment( _raise_api_error(error, "创建环境") raise + @app.get("/web/v3/environments/{environment_id}") @app.get("/web/environments/{environment_id}") async def get_environment( environment_id: str, @@ -92,6 +96,7 @@ async def get_environment( _raise_api_error(error, "读取环境") raise + @app.post("/web/v3/environments/{environment_id}/share-code") @app.post("/web/environments/{environment_id}/share-code") async def export_environment_share_code( environment_id: str, @@ -104,6 +109,7 @@ async def export_environment_share_code( _raise_api_error(error, "导出环境分享码") raise + @app.post("/web/v3/environment-share-codes/inspect") @app.post("/web/environment-share-codes/inspect") async def inspect_environment_share_codes( body: EnvironmentShareCodesRequest, @@ -116,6 +122,7 @@ async def inspect_environment_share_codes( _raise_api_error(error, "解析环境分享码") raise + @app.post("/web/v3/environment-share-codes/import") @app.post("/web/environment-share-codes/import") async def import_environment_share_codes( body: EnvironmentShareCodesRequest, @@ -128,6 +135,7 @@ async def import_environment_share_codes( _raise_api_error(error, "导入环境分享码") raise + @app.patch("/web/v3/environments/{environment_id}") @app.patch("/web/environments/{environment_id}") async def update_environment( environment_id: str, @@ -141,6 +149,8 @@ async def update_environment( _raise_api_error(error, "更新环境") raise + @app.delete("/web/v3/environments/{environment_id}", status_code=204) + @app.post("/web/v3/environments/{environment_id}/delete", status_code=204) @app.delete("/web/environments/{environment_id}", status_code=204) @app.post("/web/environments/{environment_id}/delete", status_code=204) async def delete_environment(environment_id: str, request: Request) -> None: @@ -151,6 +161,7 @@ async def delete_environment(environment_id: str, request: Request) -> None: _raise_api_error(error, "删除环境") raise + @app.post("/web/v3/environments/{environment_id}/build", status_code=202) @app.post("/web/environments/{environment_id}/build", status_code=202) async def build_environment( environment_id: str, @@ -163,6 +174,7 @@ async def build_environment( _raise_api_error(error, "启动环境镜像构建") raise + @app.get("/web/v3/environments/{environment_id}/builds/{version_id}") @app.get("/web/environments/{environment_id}/builds/{version_id}") async def get_environment_build( environment_id: str, @@ -184,6 +196,7 @@ async def get_environment_build( _raise_api_error(error, "读取环境镜像构建状态") raise + @app.get("/web/v3/environments/{environment_id}/builds/{version_id}/manifest") @app.get("/web/environments/{environment_id}/builds/{version_id}/manifest") async def get_environment_manifest( environment_id: str, @@ -192,13 +205,17 @@ async def get_environment_manifest( ) -> dict[str, Any]: owner_id = identity_resolver(request) try: - return _public( - await service.get_manifest(owner_id, environment_id, version_id) - ) + manifest = await service.get_manifest(owner_id, environment_id, version_id) + if not request.url.path.startswith("/web/v3/"): + manifest = manifest.model_copy( + update={"api_version": "agentkit.studio/v1alpha1"} + ) + return _public(manifest) except Exception as error: _raise_api_error(error, "读取环境 Manifest") raise + @app.get("/web/v3/environment-resources") @app.get("/web/environment-resources") async def environment_resources(request: Request) -> dict[str, Any]: _ = identity_resolver(request) diff --git a/frontend/server/environments/service.py b/frontend/server/environments/service.py index cdc8b910c..a15598ac4 100644 --- a/frontend/server/environments/service.py +++ b/frontend/server/environments/service.py @@ -633,7 +633,7 @@ async def get_manifest( owner_id, environment_id, version_id ) return EnvironmentManifest( - apiVersion="agentkit.studio/v1alpha1", + apiVersion="agentkit.studio/v3", metadata=EnvironmentManifestMetadata( id=environment.id, name=environment.name, @@ -660,6 +660,52 @@ async def get_manifest( ), ) + async def ensure_sandbox_tool_ready( + self, + owner_id: str, + environment_id: str, + version_id: str, + ) -> EnvironmentBuild: + """Validate or repair the Tool for an already-built Sandbox image.""" + + repository = self._require_repository() + environment = await repository.get_version_config( + owner_id, environment_id, version_id + ) + if environment.base_environment not in {"aio-sandbox", "codex-sandbox"}: + raise ValueError("所选环境不支持 Sandbox 命令执行。") + build = await repository.get_build(owner_id, environment_id, version_id) + if build.status != "available" or not build.image.strip(): + raise ValueError("所选环境版本尚未构建完成。") + if self._tool_provisioner is None: + raise RuntimeError("AgentKit Sandbox Tool 服务未配置。") + resources = build.resources + if not isinstance(resources, EnvironmentResources): + raise TypeError("环境构建记录缺少云资源信息。") + + tool = await self._tool_provisioner.ensure_ready( + image=build.image, + provider=resources.provider, + region=resources.region, + existing_tool_id=build.tool_id, + ) + if build.tool_id == tool.tool_id and build.tool_status == tool.status: + return build + current = await repository.get_build(owner_id, environment_id, version_id) + repaired = current.model_copy( + update={ + "status": "available", + "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(), + } + ) + return await repository.update_build(owner_id, repaired) + def resource_info(self) -> EnvironmentResourceInfo: return self._require_cloud().describe() @@ -684,7 +730,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 +759,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 +821,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 +878,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 +886,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..e63a702c6 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,86 @@ 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) + if existing_tool_id: + try: + match = client.get_tool( + tools_types.GetToolRequest(ToolId=existing_tool_id) + ) + except Exception: + # Build metadata can outlive a manually deleted cloud Tool. Recover + # from the immutable image instead of forcing an image rebuild. + match = _find_tool(client, tools_types, name) + else: + match = _find_tool(client, tools_types, name) created_new = match is None if match is None: try: @@ -251,9 +297,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 +336,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 +381,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..f72c73c96 100644 --- a/frontend/server/studio_tools/__init__.py +++ b/frontend/server/studio_tools/__init__.py @@ -14,22 +14,36 @@ """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, open_studio_tool_run, runtime_supports_bff_tools, ) +from frontend.server.studio_tools.local import ( + LocalStudioToolDispatcher, + build_local_studio_tools, + ensure_local_studio_toolset, + local_progress_sse_event, + stream_local_studio_response, +) from frontend.server.studio_tools.registry import ( StudioTool, StudioToolCatalogSnapshot, StudioToolExecutionContext, StudioToolRegistry, + StudioToolRuntimeError, build_studio_tool_registry, ) from frontend.server.studio_tools.sandbox_shell import ( AgentkitEnvironmentSandboxResolver, SandboxExecutionTarget, + SandboxResolutionError, SandboxTargetResolver, execute_in_sandbox, register_sandbox_shell_tool, @@ -37,17 +51,27 @@ __all__ = [ "AgentkitEnvironmentSandboxResolver", + "CodexSandboxConnection", + "CodexSandboxDelegate", "SandboxExecutionTarget", + "SandboxResolutionError", "SandboxTargetResolver", + "LocalStudioToolDispatcher", "StudioChannelError", "StudioTool", "StudioToolCatalogSnapshot", "StudioToolExecutionContext", "StudioToolRegistry", "StudioToolRun", + "StudioToolRuntimeError", "build_studio_tool_registry", + "build_local_studio_tools", + "ensure_local_studio_toolset", "execute_in_sandbox", + "local_progress_sse_event", "open_studio_tool_run", + "register_codex_sandbox_tool", "register_sandbox_shell_tool", "runtime_supports_bff_tools", + "stream_local_studio_response", ] diff --git a/frontend/server/studio_tools/codex_sandbox.py b/frontend/server/studio_tools/codex_sandbox.py new file mode 100644 index 000000000..26bb9f668 --- /dev/null +++ b/frontend/server/studio_tools/codex_sandbox.py @@ -0,0 +1,857 @@ +# 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 +_CODEX_TOOL_TIMEOUT_MS = 30 * 60 * 1_000 +_MAX_RESULT_CHARACTERS = 32_768 +_MAX_RESULT_BYTES = 96 * 1024 +_MAX_PROGRESS_BYTES = 64 * 1024 +_MAX_PROGRESS_STRING_CHARACTERS = 16_000 +_MAX_PROGRESS_COLLECTION_ITEMS = 100 +_MAX_ACTIVITY_BYTES = 16 * 1024 +_MAX_ACTIVITY_EVENTS = 100 +_MAX_ACTIVITY_STRING_CHARACTERS = 4_000 +_MAX_ACTIVITY_COLLECTION_ITEMS = 30 +_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] = [] + final_text = "" + activity_events: list[dict[str, Any]] = [] + text_event_id = f"assistant:{context.tool_request_id or context.run_id}" + + existing = await self._connection(target, mount, context) + if existing.lock.locked(): + 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=existing.connection.thread_id, + ok=False, + ), + ) + + 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": + if event.text: + _append_text_part(text_parts, event.text) + # The activity card describes execution. The authoritative + # assistant answer is emitted once as ordinary message text + # after the function response, not duplicated in the card. + continue + if event.kind in {"assistant_final", "final"}: + if event.text: + final_text = event.text + 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((final_text or "".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": _bounded_result_message(message), + } + + 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." + " Preserve the user's requested scope instead of adding new length " + "or format requirements. The inner Codex final response must contain " + "the user-visible deliverable; a Sandbox-local file path alone is " + "not a deliverable unless the user explicitly requested a file." + " Call this tool at most once per outer user turn. If it reports " + "busy or timeout, surface that state and do not retry automatically." + " Treat a successful delegation as the final tool call of the outer " + "turn because its message is returned directly to the user." + ), + 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", + # Codex performs complete repository workflows. Keep this absolute + # channel deadline separate from ordinary tools; the app-server turn + # still enforces its shorter inactivity timeout and interrupts on it. + timeout_ms=_CODEX_TOOL_TIMEOUT_MS, + 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." + ), + ( + "Put the complete user-visible deliverable in your final response. " + "Do not only save it to a Sandbox-local file or return a file path " + "unless the task explicitly requests a file." + ), + ( + "Unless the user explicitly requests a file, do not create a file " + "for prose-only deliverables. Return exactly one final answer, do " + "not emit the full deliverable as an intermediate update, and do " + "not repeat it after the final answer. When no length is specified, " + "keep the final answer complete but concise enough to return " + "directly to the user." + ), + "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 + + +def _bounded_result_message(value: str) -> str: + """Keep complete ordinary results and preserve the start of oversized ones.""" + + if not value: + return "Codex Sandbox 已完成任务。" + encoded = value.encode("utf-8") + if len(value) <= _MAX_RESULT_CHARACTERS and len(encoded) <= _MAX_RESULT_BYTES: + return value + marker = "\n\n…内容过长,已截断" + marker_bytes = marker.encode("utf-8") + candidate = value[:_MAX_RESULT_CHARACTERS].encode("utf-8") + available = _MAX_RESULT_BYTES - len(marker_bytes) + return candidate[:available].decode("utf-8", errors="ignore") + marker + + +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: + compact = _safe_activity_value(event) + if not isinstance(compact, dict): + return + events.append(compact) + while ( + len(events) > _MAX_ACTIVITY_EVENTS or _json_size(events) > _MAX_ACTIVITY_BYTES + ): + events.pop(0) + + +def _safe_activity_value(value: Any, *, depth: int = 0) -> Any: + """Keep persisted tool responses compact; live progress is sent separately.""" + + if depth >= 6: + return "[truncated]" + if isinstance(value, str): + if len(value) <= _MAX_ACTIVITY_STRING_CHARACTERS: + return value + return value[:_MAX_ACTIVITY_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_ACTIVITY_COLLECTION_ITEMS: + result["truncated"] = True + break + key = str(raw_key)[:200] + result[key] = _safe_activity_value(item, depth=depth + 1) + return result + if isinstance(value, (list, tuple)): + items = [ + _safe_activity_value(item, depth=depth + 1) + for item in value[:_MAX_ACTIVITY_COLLECTION_ITEMS] + ] + if len(value) > _MAX_ACTIVITY_COLLECTION_ITEMS: + items.append("[truncated]") + return items + return str(value)[:_MAX_ACTIVITY_STRING_CHARACTERS] + + +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..39bd4223b 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, @@ -60,19 +61,56 @@ def _bounded_tool_result(content: Any) -> Any: encoded = json.dumps(content, ensure_ascii=False).encode("utf-8") if len(encoded) <= MAX_TOOL_RESULT_BYTES: return content - preview = encoded[:TOOL_RESULT_PREVIEW_BYTES].decode("utf-8", errors="replace") result: dict[str, Any] = { "truncated": True, "original_size_bytes": len(encoded), - "preview": preview, } if isinstance(content, dict): for key in ("ok", "error", "executed_by", "bff_process_id"): if key in content: result[key] = content[key] + message = content.get("message") + if isinstance(message, str) and message: + # A delegated Codex message is already the user-visible answer. + # Preserve it instead of replacing the whole result with a debug + # preview, which would also disable skip_summarization downstream. + result["message"] = _fit_text_field(result, "message", message) + return result + preview = encoded[:TOOL_RESULT_PREVIEW_BYTES].decode("utf-8", errors="replace") + result["preview"] = _fit_text_field(result, "preview", preview) return result +def _fit_text_field( + envelope: dict[str, Any], + field: str, + value: str, +) -> str: + """Fit one text field inside the serialized UTF-8 channel limit.""" + + suffix = "\n…内容已截断" + low = 0 + high = len(value) + best = "" + while low <= high: + middle = (low + high) // 2 + candidate = value[:middle] + if middle < len(value): + candidate += suffix + size = len( + json.dumps( + {**envelope, field: candidate}, + ensure_ascii=False, + ).encode("utf-8") + ) + if size <= MAX_TOOL_RESULT_BYTES: + best = candidate + low = middle + 1 + else: + high = middle - 1 + return best + + async def runtime_supports_bff_tools( *, endpoint: str, @@ -233,6 +271,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 +285,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 +313,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) @@ -544,6 +589,11 @@ async def open_studio_tool_run( owner_id: str = "", environment_mount: SessionEnvironmentMount | None = None, environment_mounts: Sequence[SessionEnvironmentMount] = (), + prepare_environment_mounts: Callable[ + [Sequence[SessionEnvironmentMount], StudioToolExecutionContext], + Awaitable[Sequence[SessionEnvironmentMount]], + ] + | None = None, ) -> StudioToolRun: """Connect, publish the current catalog, and start one same-socket run.""" @@ -571,6 +621,20 @@ async def open_studio_tool_run( environment_mount=environment_mount, environment_mounts=tuple(environment_mounts), ) + mounts_to_prepare = tuple(environment_mounts) or ( + (environment_mount,) if environment_mount is not None else () + ) + if mounts_to_prepare and prepare_environment_mounts is not None: + prepared_mounts = tuple( + await prepare_environment_mounts(mounts_to_prepare, execution_context) + ) + execution_context = replace( + execution_context, + environment_mount=( + prepared_mounts[0] if len(prepared_mounts) == 1 else None + ), + environment_mounts=prepared_mounts, + ) try: websocket = await connect( _websocket_url(endpoint), diff --git a/frontend/server/studio_tools/local.py b/frontend/server/studio_tools/local.py new file mode 100644 index 000000000..7b937ee2f --- /dev/null +++ b/frontend/server/studio_tools/local.py @@ -0,0 +1,272 @@ +# 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. + +"""Run Studio BFF tools directly inside a local ADK invocation.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from dataclasses import replace +from typing import Any +from uuid import uuid4 + +from google.adk.tools.base_tool import BaseTool + +from frontend.server.studio_tools.registry import ( + StudioToolCatalogSnapshot, + StudioToolExecutionContext, + StudioToolExecutionError, + StudioToolRuntimeError, +) +from veadk.integrations.agentkit.studio_channel import ( + StudioExternalToolset, + StudioRemoteTool, + bind_studio_tools, +) +from veadk.integrations.agentkit.studio_channel.protocol import StudioToolManifest + +logger = logging.getLogger(__name__) + +ProgressReporter = Callable[[dict[str, Any]], Awaitable[None]] + + +class LocalStudioToolDispatcher: + """Adapt a frozen BFF catalog to the Runtime's model-visible tools.""" + + def __init__( + self, + *, + catalog: StudioToolCatalogSnapshot, + context: StudioToolExecutionContext, + report_progress: ProgressReporter, + ) -> None: + self._catalog = catalog + self._context = context + self._report_progress = report_progress + + async def call_tool( + self, + *, + run_id: str, + scope_id: str, + catalog_revision: str, + manifest: StudioToolManifest, + arguments: dict[str, Any], + function_call_id: str = "", + ) -> Any: + """Execute one catalog tool with the same status contract as the channel.""" + + if ( + run_id != self._context.run_id + or scope_id != self._context.scope_id + or catalog_revision != self._context.catalog_revision + ): + return { + "status": "denied", + "error": "Studio tool call context mismatch.", + } + + request_id = function_call_id or uuid4().hex + + async def report(progress: dict[str, Any]) -> None: + await self._report_progress( + { + "toolName": manifest.name, + "requestId": request_id, + **progress, + } + ) + + context = replace( + self._context, + tool_request_id=request_id, + report_progress=report, + ) + try: + return await asyncio.wait_for( + self._catalog.execute( + name=manifest.name, + executor_revision=manifest.executor_revision, + arguments=arguments, + context=context, + ), + timeout=manifest.timeout_ms / 1000, + ) + except asyncio.TimeoutError: + return { + "status": "timeout", + "error": ( + "Codex Sandbox 长任务超过 30 分钟,已停止执行;" + "请确认当前状态后再决定是否重新提交。" + if manifest.name == "delegate_to_codex_sandbox" + else "Studio tool timed out." + ), + } + except StudioToolRuntimeError as error: + response = dict(error.content) if isinstance(error.content, dict) else {} + response.update(status="runtime_error", error=str(error)) + return response + except StudioToolExecutionError as error: + return {"status": "denied", "error": str(error)} + except Exception: # noqa: BLE001 - local tool safety boundary + logger.exception( + "Local Studio tool execution failed tool=%s run_id=%s", + manifest.name, + run_id, + ) + return { + "status": "error", + "error": "Studio BFF tool execution failed.", + } + + +def build_local_studio_tools( + *, + catalog: StudioToolCatalogSnapshot, + context: StudioToolExecutionContext, + report_progress: ProgressReporter, +) -> tuple[BaseTool, ...]: + """Build immutable model-visible wrappers for one local Agent run.""" + + dispatcher = LocalStudioToolDispatcher( + catalog=catalog, + context=context, + report_progress=report_progress, + ) + return tuple( + StudioRemoteTool( + manifest=StudioToolManifest.model_validate(manifest), + dispatcher=dispatcher, + run_id=context.run_id, + scope_id=context.scope_id, + catalog_revision=context.catalog_revision, + ) + for manifest in catalog.manifests() + ) + + +def ensure_local_studio_toolset( + runner: Any, + selected_names: Sequence[str], +) -> None: + """Attach the run-scoped Studio toolset to a cached local ADK runner.""" + + app = getattr(runner, "app", None) + root_agent = getattr(app, "root_agent", None) + tools = getattr(root_agent, "tools", None) + if not isinstance(tools, list): + raise StudioToolExecutionError( + "The selected local Agent cannot accept Studio tools." + ) + selected = set(selected_names) + reserved = { + str(getattr(tool, "name", "") or "") + for tool in tools + if not isinstance(tool, StudioExternalToolset) + } + conflicts = sorted(selected.intersection(reserved)) + if conflicts: + raise StudioToolExecutionError( + "Studio tool names conflict with local Agent tools: " + ", ".join(conflicts) + ) + if not any(isinstance(tool, StudioExternalToolset) for tool in tools): + tools.append(StudioExternalToolset()) + + +def local_progress_sse_event( + *, + app_name: str, + progress: dict[str, Any], +) -> bytes: + """Encode local tool progress with the existing Studio SSE contract.""" + + request_id = str(progress.get("requestId") or uuid4().hex) + event = { + "id": f"studio-tool-progress:{request_id}:{uuid4().hex}", + "author": app_name, + "partial": True, + "content": { + "role": "model", + "parts": [ + { + "partMetadata": { + "veadkStudioToolProgress": progress, + } + } + ], + }, + } + return ( + "data: " + json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n\n" + ).encode("utf-8") + + +async def stream_local_studio_response( + source: AsyncIterator[bytes | str], + *, + tools: Sequence[BaseTool], + progress_events: asyncio.Queue[bytes], +) -> AsyncIterator[bytes | str]: + """Merge direct tool progress into the local ADK SSE response.""" + + source_task: asyncio.Task[bytes | str] | None = None + progress_task: asyncio.Task[bytes] | None = None + iterator = source.__aiter__() + try: + with bind_studio_tools(tools): + while True: + if source_task is None: + source_task = asyncio.create_task(anext(iterator)) + if progress_task is None: + progress_task = asyncio.create_task(progress_events.get()) + done, _ = await asyncio.wait( + {source_task, progress_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if progress_task in done: + yield progress_task.result() + progress_task = None + if source_task in done: + try: + chunk = source_task.result() + except StopAsyncIteration: + source_task = None + while not progress_events.empty(): + yield progress_events.get_nowait() + return + source_task = None + yield chunk + finally: + for task in (source_task, progress_task): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather( + *(task for task in (source_task, progress_task) if task is not None), + return_exceptions=True, + ) + aclose = getattr(iterator, "aclose", None) + if callable(aclose): + await aclose() + + +__all__ = [ + "LocalStudioToolDispatcher", + "build_local_studio_tools", + "ensure_local_studio_toolset", + "local_progress_sse_event", + "stream_local_studio_response", +] 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..d765e2073 100644 --- a/frontend/server/studio_tools/sandbox_shell.py +++ b/frontend/server/studio_tools/sandbox_shell.py @@ -20,7 +20,7 @@ import hashlib import json import time -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from typing import Any, Protocol from urllib.parse import urlsplit @@ -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 @@ -97,30 +96,17 @@ def __init__( self._targets: dict[tuple[str, str, str, str, str], _CachedTarget] = {} self._locks: dict[tuple[str, str, str, str, str], asyncio.Lock] = {} - async def resolve( + async def prepare( self, mount: SessionEnvironmentMount, context: StudioToolExecutionContext, ) -> SandboxExecutionTarget: - key = (*_context_key(context), mount.environment_id) - cached = self._targets.get(key) - if ( - cached is not None - and cached.image == mount.image - and cached.tool_id == mount.tool_id - ): - return cached.target + """Validate the mounted Tool and prepare its Agent-session Sandbox.""" + key = _target_key(context, mount) + mount_identity = _mount_identity(mount) 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 - ): - return cached.target - if not mount.tool_id or mount.tool_status != _READY_STATUS: raise SandboxResolutionError( "The mounted environment version does not have a persisted Tool " @@ -129,14 +115,54 @@ async def resolve( client = self._client_factory(mount.provider, mount.region) tool_id = mount.tool_id await _require_ready_tool(client, tool_id, mount.image) + cached = self._targets.get(key) + if cached is not None and cached.mount_identity == mount_identity: + if await _cached_session_is_ready(client, cached.target): + return cached.target + self._targets.pop(key, None) 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 + async def prepare_many( + self, + mounts: Sequence[SessionEnvironmentMount], + context: StudioToolExecutionContext, + ) -> tuple[SandboxExecutionTarget, ...]: + """Prepare every mounted environment before the Agent can call tools.""" + + return tuple( + await asyncio.gather(*(self.prepare(mount, context) for mount in mounts)) + ) + + def is_prepared( + self, + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + ) -> bool: + """Return whether this exact attachment already has a cached target.""" + + cached = self._targets.get(_target_key(context, mount)) + return cached is not None and cached.mount_identity == _mount_identity(mount) + + async def resolve( + self, + mount: SessionEnvironmentMount, + context: StudioToolExecutionContext, + ) -> SandboxExecutionTarget: + """Return a pre-created Sandbox; tool execution never creates one.""" + + cached = self._targets.get(_target_key(context, mount)) + if self.is_prepared(mount, context) and cached is not None: + return cached.target + raise SandboxResolutionError( + "The mounted Sandbox Session was not prepared for this Agent session. " + "Remount the environment or retry the Agent request." + ) + async def _session_for_mount( self, client: Any, @@ -178,17 +204,19 @@ 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, + _get_session, + client, 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 +255,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 +280,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 +324,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 +369,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 +583,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 +598,32 @@ def _context_key( ) +def _target_key( + context: StudioToolExecutionContext, + mount: SessionEnvironmentMount, +) -> tuple[str, str, str, str, str]: + """Bind one cached target to the Agent session and attachment instance.""" + + mount_key = mount.mount_instance_id or mount.environment_id + return (*_context_key(context), mount_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 +634,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, @@ -604,6 +677,15 @@ def _get_tool(client: Any, request: Any) -> Any: ) from error +def _get_session(client: Any, request: Any) -> Any: + try: + return client.get_session(request) + except StopIteration as error: + raise LookupError( + "AgentKit did not return the requested Sandbox Session." + ) from error + + async def _require_ready_tool(client: Any, tool_id: str, image: str) -> None: from agentkit.sdk.tools import types as tools_types @@ -629,6 +711,36 @@ async def _require_ready_tool(client: Any, tool_id: str, image: str) -> None: ) +async def _cached_session_is_ready( + client: Any, + target: SandboxExecutionTarget, +) -> bool: + """Return whether a cached Sandbox Session still exists and is usable.""" + + from agentkit.sdk.tools import types as tools_types + + try: + session = await asyncio.to_thread( + _get_session, + client, + tools_types.GetSessionRequest( + ToolId=target.tool_id, + SessionId=target.session_id, + ), + ) + except Exception: + return False + status = str(getattr(session, "status", "") or "").strip().lower() + endpoint = str(getattr(session, "endpoint", "") or "").strip() + if status != _READY_STATUS or not endpoint: + return False + try: + _validated_endpoint(endpoint) + except StudioToolExecutionError: + return False + return True + + __all__ = [ "AgentkitEnvironmentSandboxResolver", "SandboxExecutionTarget", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e3c002fe..3fce8a2f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import { listWorkspaces, listModelOptions, listSessions, + prepareSessionEnvironmentMounts, RUN_SSE_INCOMPLETE_RESPONSE_ERROR, runSSE, refreshAgentFeedbackCases, @@ -402,7 +403,83 @@ const ENVIRONMENT_STUDIO_TOOL_IDS = [ "list_envs", "get_env_manifest", "execute_in_sandbox", + "delegate_to_codex_sandbox", ] as const; +const SESSION_ENVIRONMENT_STORAGE_KEY = "veadk.sessionEnvironmentMounts.v1"; + +interface StoredSessionEnvironmentState { + mounts: Record; + workspaceIds: Record; +} + +function emptyStoredSessionEnvironmentState(): StoredSessionEnvironmentState { + return { mounts: {}, workspaceIds: {} }; +} + +function loadStoredSessionEnvironmentState(): StoredSessionEnvironmentState { + if (typeof localStorage === "undefined") { + return emptyStoredSessionEnvironmentState(); + } + try { + const raw = JSON.parse( + localStorage.getItem(SESSION_ENVIRONMENT_STORAGE_KEY) ?? "{}", + ) as Record; + const readRecord = ( + value: unknown, + readItems: (value: unknown) => T[], + ): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .slice(-200) + .map(([key, items]) => [key, readItems(items)]), + ); + }; + const mounts = readRecord(raw.mounts, (value) => { + if (!Array.isArray(value)) return []; + return value.slice(0, 20).flatMap((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const candidate = item as Record; + if ( + typeof candidate.environment_id !== "string" || + typeof candidate.environment_version_id !== "string" || + (candidate.mount_instance_id !== undefined && + typeof candidate.mount_instance_id !== "string") + ) return []; + return [{ + environment_id: candidate.environment_id, + environment_version_id: candidate.environment_version_id, + ...(candidate.mount_instance_id + ? { mount_instance_id: candidate.mount_instance_id } + : {}), + }]; + }); + }); + const workspaceIds = readRecord(raw.workspaceIds, (value) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string").slice(0, 20) + : [], + ); + return { mounts, workspaceIds }; + } catch { + return emptyStoredSessionEnvironmentState(); + } +} + +function persistSessionEnvironmentState( + mounts: Record, + workspaceIds: Record, +) { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem( + SESSION_ENVIRONMENT_STORAGE_KEY, + JSON.stringify({ mounts, workspaceIds }), + ); + } catch { + // Storage can be unavailable in private or quota-restricted browsers. + } +} function emptyInvocation(): FrontendInvocation { return { skills: [] }; @@ -1374,12 +1451,25 @@ export default function App() { const [sessionWorkspaces, setSessionWorkspaces] = useState([]); const [sessionEnvironmentsLoading, setSessionEnvironmentsLoading] = useState(false); const [sessionEnvironmentsError, setSessionEnvironmentsError] = useState(""); + const sessionEnvironmentLoadAbortRef = useRef(null); + const storedSessionEnvironmentState = useRef( + null, + ); + if (storedSessionEnvironmentState.current === null) { + storedSessionEnvironmentState.current = loadStoredSessionEnvironmentState(); + } const [environmentMountsBySession, setEnvironmentMountsBySession] = useState< Record - >({}); + >(() => storedSessionEnvironmentState.current?.mounts ?? {}); const [environmentWorkspaceIdsBySession, setEnvironmentWorkspaceIdsBySession] = useState< Record - >({}); + >(() => storedSessionEnvironmentState.current?.workspaceIds ?? {}); + useEffect(() => { + persistSessionEnvironmentState( + environmentMountsBySession, + environmentWorkspaceIdsBySession, + ); + }, [environmentMountsBySession, environmentWorkspaceIdsBySession]); const [runtimeLogTargetsBySession, setRuntimeLogTargetsBySession] = useState< Record >({}); @@ -4938,7 +5028,7 @@ export default function App() { : environmentMountsBySession[ studioToolSelectionKey(appName, userId, sessionId) ] ?? []; - if (environmentMounts.length > 0 && currentRuntime) { + if (environmentMounts.length > 0 && studioToolRuntime) { platformTools = [...new Set([...platformTools, ...ENVIRONMENT_STUDIO_TOOL_IDS])]; } const sessionState = createsSession ? "new" : "existing"; @@ -5005,7 +5095,7 @@ export default function App() { const agentTools = new Set(agentInfo?.tools ?? []); const availableTools = new Set([ ...agentTools, - ...(currentRuntime ? availableStudioToolIds : []), + ...(studioToolRuntime ? availableStudioToolIds : []), ]); const missingTools = requiredTools.filter((tool) => !availableTools.has(tool)); if (missingTools.length > 0) { @@ -5027,7 +5117,7 @@ export default function App() { setError(`当前 Agent 缺少任务工具:${missingTools.join("、")}`); return; } - if (currentRuntime) { + if (studioToolRuntime) { const optionalTools = NEW_CHAT_TASK_OPTIONAL_TOOLS[selectedTask].filter( (toolName) => availableStudioToolIds.has(toolName) && !agentTools.has(toolName), ); @@ -5043,7 +5133,7 @@ export default function App() { createsSession ? optimisticTurns : [...current, ...optimisticTurns], ); if (createsSession) { - if (currentRuntime) { + if (studioToolRuntime) { const key = studioToolSelectionKey(appName, userId, sid); setStudioToolIdsBySession((current) => ({ ...current, @@ -5084,8 +5174,8 @@ export default function App() { text, attachments: atts, invocation: selectedInvocation, - platformTools: currentRuntime ? platformTools : undefined, - environmentMounts: currentRuntime && environmentMounts.length > 0 + platformTools: studioToolRuntime ? platformTools : undefined, + environmentMounts: studioToolRuntime && environmentMounts.length > 0 ? environmentMounts : undefined, signal: ctrl.signal, @@ -5289,8 +5379,8 @@ export default function App() { functionResponses: [ { id: block.callId, name: "adk_request_credential", response }, ], - platformTools: currentRuntime ? resumedPlatformTools : undefined, - environmentMounts: currentRuntime && environmentMounts.length > 0 + platformTools: studioToolRuntime ? resumedPlatformTools : undefined, + environmentMounts: studioToolRuntime && environmentMounts.length > 0 ? environmentMounts : undefined, signal: ctrl.signal, @@ -5404,7 +5494,18 @@ export default function App() { : undefined; const selectedDraftStudioRuntime = draftStudioRuntime?.appName === appName ? draftStudioRuntime : undefined; - const studioToolRuntime = currentRuntime ?? selectedDraftStudioRuntime; + // Local Agents execute through this Studio process, so use the synthetic + // `local` runtime only for BFF capability discovery. ADK request routing is + // still derived from `appName` and therefore remains on the local /run_sse. + const studioToolRuntime = currentRuntime ?? selectedDraftStudioRuntime ?? ( + appName + ? { + runtimeId: "local", + name: "Local Studio", + region: defaultCloudRegion(cloudProvider), + } + : undefined + ); useEffect(() => { let cancelled = false; @@ -5449,11 +5550,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 +5614,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, ]); @@ -5611,16 +5750,34 @@ export default function App() { [activeStudioToolSelectionKey]: next, })); }; - const updateSelectedEnvironments = ( + const updateSelectedEnvironments = async ( selections: SessionEnvironmentMountSelection[], workspaceIds: string[] = [], - ) => { - if (!sessionId) return; + ): Promise => { + if (!sessionId) throw new Error("当前会话不存在,无法挂载环境。"); const valid = selections.every((selection) => sessionEnvironments.some((environment) => environment.id === selection.environment_id && environment.latestVersion?.versionId === selection.environment_version_id )); - if (!valid) return; + if (!valid) throw new Error("所选环境已失效,请刷新后重新选择。"); + if (selections.length > 0) { + if (!studioToolRuntime) { + throw new Error("当前 Agent 没有可用的 Sandbox Runtime。"); + } + setSessionEnvironmentsError(""); + try { + await prepareSessionEnvironmentMounts({ + runtimeId: studioToolRuntime.runtimeId, + appName, + userId, + sessionId, + environmentMounts: selections, + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "挂载环境失败"; + throw new Error(message); + } + } setEnvironmentMountsBySession((current) => ({ ...current, [activeStudioToolSelectionKey]: selections, @@ -7740,6 +7897,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..dce36c9f7 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2107,11 +2107,101 @@ 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 interface PreparedSessionEnvironmentMount { + environment_id: string; + environment_version_id: string; + mount_instance_id: string; + sandbox_session_id: string; +} + +export function parsePreparedSessionEnvironmentMounts( + value: unknown, + expectedMounts: readonly SessionEnvironmentMountSelection[], +): PreparedSessionEnvironmentMount[] { + const payload = value as { mounts?: unknown }; + if (!payload || typeof payload !== "object" || !Array.isArray(payload.mounts)) { + throw new Error("挂载环境响应格式无效"); + } + if (payload.mounts.length !== expectedMounts.length) { + throw new Error("挂载环境响应与请求不一致"); + } + return payload.mounts.map((item, index) => { + const expected = expectedMounts[index]; + if ( + !item || + typeof item !== "object" || + typeof item.environment_id !== "string" || + typeof item.environment_version_id !== "string" || + typeof item.mount_instance_id !== "string" || + typeof item.sandbox_session_id !== "string" || + !item.environment_id || + !item.environment_version_id || + !item.mount_instance_id || + !item.sandbox_session_id + ) { + throw new Error("挂载环境响应格式无效"); + } + if ( + item.environment_id !== expected.environment_id || + item.environment_version_id !== expected.environment_version_id || + (expected.mount_instance_id !== undefined && + item.mount_instance_id !== expected.mount_instance_id) + ) { + throw new Error("挂载环境响应与请求不一致"); + } + return item as PreparedSessionEnvironmentMount; + }); +} + +export async function prepareSessionEnvironmentMounts({ + runtimeId, + appName, + userId, + sessionId, + environmentMounts, +}: { + runtimeId: string; + appName: string; + userId: string; + sessionId: string; + environmentMounts: readonly SessionEnvironmentMountSelection[]; +}): Promise { + const { app } = resolve(appName); + let response: Response; + try { + response = await apiFetch("/web/v3/session-environment-mounts/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runtime_id: runtimeId, + app_name: app, + user_id: userId, + session_id: sessionId, + environment_mounts: [...environmentMounts], + }), + }); + } catch (error) { + if (error instanceof TypeError) { + throw new Error("无法连接 Studio 服务,环境挂载未完成。请检查网络后重试。"); + } + throw error; + } + if (!response.ok) { + throw new Error(await httpErrorMessage(response, "挂载环境失败")); + } + return parsePreparedSessionEnvironmentMounts( + await response.json(), + environmentMounts, + ); } export type EnvironmentBuildStatus = | "preparing" @@ -2183,7 +2273,7 @@ export interface EnvironmentManifestSkill { } export interface EnvironmentManifest { - apiVersion: "agentkit.studio/v1alpha1"; + apiVersion: "agentkit.studio/v3" | "agentkit.studio/v1alpha1"; kind: "Environment"; metadata: { id: string; @@ -2459,13 +2549,15 @@ 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 响应格式无效"); } const candidate = value as EnvironmentManifest; if ( - candidate.apiVersion !== "agentkit.studio/v1alpha1" || + !["agentkit.studio/v3", "agentkit.studio/v1alpha1"].includes( + candidate.apiVersion, + ) || candidate.kind !== "Environment" || !candidate.metadata || typeof candidate.metadata.id !== "string" || @@ -2474,7 +2566,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 +2644,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 +2660,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), @@ -2655,7 +2754,7 @@ export async function deleteWorkspace( } export async function listEnvironments(signal?: AbortSignal): Promise { - const response = await apiFetch("/web/environments", { signal }); + const response = await apiFetch("/web/v3/environments", { signal }); if (!response.ok) { throw new Error(await httpErrorMessage(response, "加载环境失败")); } @@ -2668,7 +2767,7 @@ export async function inspectEnvironmentRepository( input: { repositoryUrl: string; ref?: string }, signal?: AbortSignal, ): Promise { - const response = await apiFetch("/web/environment-repositories/inspect", { + const response = await apiFetch("/web/v3/environment-repositories/inspect", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), @@ -2695,7 +2794,7 @@ export async function exportEnvironmentShareCode( signal?: AbortSignal, ): Promise { const response = await apiFetch( - `/web/environments/${encodeURIComponent(environmentId)}/share-code`, + `/web/v3/environments/${encodeURIComponent(environmentId)}/share-code`, { method: "POST", signal }, ); if (!response.ok) { @@ -2712,7 +2811,7 @@ export async function inspectEnvironmentShareCodes( shareCodes: string[], signal?: AbortSignal, ): Promise { - const response = await apiFetch("/web/environment-share-codes/inspect", { + const response = await apiFetch("/web/v3/environment-share-codes/inspect", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shareCodes }), @@ -2762,7 +2861,7 @@ export async function importEnvironmentShareCodes( shareCodes: string[], signal?: AbortSignal, ): Promise { - const response = await apiFetch("/web/environment-share-codes/import", { + const response = await apiFetch("/web/v3/environment-share-codes/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shareCodes }), @@ -2812,12 +2911,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, "保存环境失败")); } @@ -2828,7 +2936,7 @@ export function createEnvironment( input: EnvironmentInput, signal?: AbortSignal, ): Promise { - return writeEnvironment("/web/environments", "POST", input, signal); + return writeEnvironment("/web/v3/environments", "POST", input, signal); } export function updateEnvironment( @@ -2837,7 +2945,7 @@ export function updateEnvironment( signal?: AbortSignal, ): Promise { return writeEnvironment( - `/web/environments/${encodeURIComponent(environmentId)}`, + `/web/v3/environments/${encodeURIComponent(environmentId)}`, "PATCH", input, signal, @@ -2849,7 +2957,7 @@ export async function deleteEnvironment( signal?: AbortSignal, ): Promise { const response = await apiFetch( - `/web/environments/${encodeURIComponent(environmentId)}`, + `/web/v3/environments/${encodeURIComponent(environmentId)}`, { method: "DELETE", signal }, ); if (!response.ok) { @@ -2862,7 +2970,7 @@ export async function buildEnvironment( signal?: AbortSignal, ): Promise { const response = await apiFetch( - `/web/environments/${encodeURIComponent(environmentId)}/build`, + `/web/v3/environments/${encodeURIComponent(environmentId)}/build`, { method: "POST", signal }, ); if (!response.ok) { @@ -2880,7 +2988,7 @@ export async function getEnvironmentBuild( ): Promise { const query = options.includeLogs ? "?includeLogs=true" : ""; const response = await apiFetch( - `/web/environments/${encodeURIComponent(environmentId)}/builds/${encodeURIComponent(versionId)}${query}`, + `/web/v3/environments/${encodeURIComponent(environmentId)}/builds/${encodeURIComponent(versionId)}${query}`, { signal: options.signal }, ); if (!response.ok) { @@ -2897,13 +3005,13 @@ export async function getEnvironmentManifest( signal?: AbortSignal, ): Promise { const response = await apiFetch( - `/web/environments/${encodeURIComponent(environmentId)}/builds/${encodeURIComponent(versionId)}/manifest`, + `/web/v3/environments/${encodeURIComponent(environmentId)}/builds/${encodeURIComponent(versionId)}/manifest`, { signal }, ); 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 { @@ -2923,7 +3031,7 @@ function environmentBuildResource(value: unknown): EnvironmentBuildResource { export async function getEnvironmentResources( signal?: AbortSignal, ): Promise { - const response = await apiFetch("/web/environment-resources", { signal }); + const response = await apiFetch("/web/v3/environment-resources", { signal }); if (!response.ok) { throw new Error(await httpErrorMessage(response, "加载环境构建资源失败")); } diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts index 6134779ce..97b06b005 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,69 @@ 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" { + // The successful function response owns the final assistant message. Older + // servers may also stream it as Codex progress; do not duplicate it inside + // the nested execution card. + if (progress.event.finalAnswer) return "applied"; + 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"; +} + +function codexDirectAnswer(response: unknown): string { + if (!response || typeof response !== "object" || Array.isArray(response)) return ""; + const result = response as Record; + if (result.ok !== true || typeof result.message !== "string") return ""; + return result.message.trim(); } const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call; @@ -319,6 +397,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 +405,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 +428,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 +446,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 +488,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 +534,29 @@ 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) ) { + const previousAnswer = isCodexTool + ? codexDirectAnswer(b.response) + : ""; b.done = true; b.response = fr.response; + if (isCodexTool) { + b.codexActivity = hydrateCodexSandboxActivity( + b.codexActivity, + fr.response, + ); + b.status = codexResponseStatus(fr.response); + const answer = codexDirectAnswer(fr.response); + if (answer && answer !== previousAnswer) { + appendText(blocks, "text", answer); + } + } break; } } @@ -457,7 +579,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..b7c6edc0b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -952,12 +952,12 @@ body { flex: 1; min-width: 0; white-space: nowrap; - overflow-x: hidden; - overflow-y: hidden; + overflow: hidden; text-overflow: clip; - scrollbar-width: none; --history-title-left-fade: 0px; --history-title-right-fade: 0px; + --history-title-translate: 0px; + --history-title-duration: 4.8s; -webkit-mask-image: linear-gradient( to right, transparent 0, @@ -973,16 +973,34 @@ body { transparent 100% ); } -.history-title::-webkit-scrollbar { display: none; } -.history-title.has-left-fade { --history-title-left-fade: 16px; } -.history-title.has-right-fade { --history-title-right-fade: 20px; } +.history-title.is-overflowing { --history-title-right-fade: 20px; } .history-title-text { display: inline-block; min-width: max-content; + transform: translate3d(0, 0, 0); } -.history-item:hover .history-title, -.history-item:focus-within .history-title { - overflow-x: auto; +.history-item:hover .history-title.is-overflowing, +.history-item:focus-within .history-title.is-overflowing { + --history-title-left-fade: 16px; +} +.history-item:hover .history-title.is-overflowing .history-title-text, +.history-item:focus-within .history-title.is-overflowing .history-title-text { + animation: history-title-marquee var(--history-title-duration) + cubic-bezier(0.45, 0, 0.25, 1) 240ms infinite; + will-change: transform; +} +@keyframes history-title-marquee { + 0%, + 12% { + transform: translate3d(0, 0, 0); + } + 68%, + 82% { + transform: translate3d(var(--history-title-translate), 0, 0); + } + 100% { + transform: translate3d(0, 0, 0); + } } .history-current-badge { flex: 0 0 auto; @@ -1034,6 +1052,10 @@ body { } } @media (prefers-reduced-motion: reduce) { + .history-title-text { + animation: none !important; + transform: none !important; + } .history-evaluating { animation: none; box-shadow: none; @@ -1476,6 +1498,117 @@ 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-x: hidden; + overflow-y: 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 +1617,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 +3019,7 @@ body { } .composer--new-chat .composer-menu-wrap { position: absolute; + z-index: 5; bottom: 10px; left: 10px; height: 36px; @@ -5898,6 +6035,9 @@ a.search-result { text-decoration: none; color: inherit; } color: hsl(var(--muted-foreground)); font-size: 12px; } +.session-environment-dialog__footer > span.is-error { + color: hsl(var(--destructive)); +} .session-environment-dialog__footer > div { display: flex; align-items: center; diff --git a/frontend/src/ui/AgentTopology.tsx b/frontend/src/ui/AgentTopology.tsx index 2c036d106..5feeb8e36 100644 --- a/frontend/src/ui/AgentTopology.tsx +++ b/frontend/src/ui/AgentTopology.tsx @@ -70,6 +70,8 @@ function uniqueValues(values: string[]): string[] { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } +const INTERNAL_AGENT_TOOL_NAMES = new Set(["StudioExternalToolset"]); + function uniqueSkills(skills: AgentInfo["skills"]): AgentInfo["skills"] { return [ ...new Map( @@ -126,7 +128,8 @@ interface AgentInfoPanelProps { onEnvironmentsChange?: ( value: SessionEnvironmentMountSelection[], workspaceIds?: string[], - ) => void; + ) => void | Promise; + onEnvironmentsRefresh?: () => void | Promise; } /** Agent metadata and optional multi-Agent topology shown in the conversation's @@ -152,6 +155,7 @@ export function AgentInfoPanel({ environmentsDisabled = false, environmentsError = "", onEnvironmentsChange, + onEnvironmentsRefresh, }: AgentInfoPanelProps) { const [dialog, setDialog] = useState<"tool" | null>(null); const [canvasExpanded, setCanvasExpanded] = useState(false); @@ -203,13 +207,15 @@ export function AgentInfoPanel({ children: [], }, ); - const baseTools = uniqueValues(info.tools).map((name) => ({ - id: `base:tool:${name}`, - name, - label: studioToolLabel(name), - custom: false, - removable: false, - })); + const baseTools = uniqueValues(info.tools) + .filter((name) => !INTERNAL_AGENT_TOOL_NAMES.has(name)) + .map((name) => ({ + id: `base:tool:${name}`, + name, + label: studioToolLabel(name), + custom: false, + removable: false, + })); const baseToolNames = new Set(baseTools.map((tool) => tool.name)); const selectedIds = new Set(selectedStudioToolIds); const managedIds = new Set(managedStudioToolIds); @@ -371,6 +377,7 @@ export function AgentInfoPanel({ disabled={environmentsDisabled} error={environmentsError} onChange={onEnvironmentsChange} + onRefresh={onEnvironmentsRefresh} /> )} @@ -479,6 +486,7 @@ export function AgentInfoDrawer({ environmentsDisabled, environmentsError, onEnvironmentsChange, + onEnvironmentsRefresh, onClose, returnFocusRef, }: { @@ -505,7 +513,8 @@ export function AgentInfoDrawer({ onEnvironmentsChange?: ( value: SessionEnvironmentMountSelection[], workspaceIds?: string[], - ) => void; + ) => void | Promise; + onEnvironmentsRefresh?: () => void | Promise; onClose: () => void; returnFocusRef: RefObject; }) { @@ -573,6 +582,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..22a72ab5c 100644 --- a/frontend/src/ui/Blocks.tsx +++ b/frontend/src/ui/Blocks.tsx @@ -1,5 +1,13 @@ import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { ChevronRight, Download, Eye, FileText, Loader2, ShieldCheck, X } from "lucide-react"; +import { + ChevronRight, + Download, + Eye, + FileText, + Loader2, + ShieldCheck, + X, +} from "lucide-react"; import { motion } from "motion/react"; import type { Block } from "../blocks"; import { buildSurfaces, SurfaceView } from "../a2ui/Surface"; @@ -54,9 +62,12 @@ function useSmoothStreamingText( useEffect(() => { const current = displayedRef.current; - const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; if (!streaming || reduceMotion || !text.startsWith(current)) { - if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current); + if (frameRef.current !== null) + window.cancelAnimationFrame(frameRef.current); frameRef.current = null; if (current !== text) { displayedRef.current = text; @@ -94,9 +105,8 @@ function useSmoothStreamingText( displayedRef.current = next; lastFrameRef.current = timestamp; setDisplayed(next); - frameRef.current = next === target - ? null - : window.requestAnimationFrame(renderFrame); + frameRef.current = + next === target ? null : window.requestAnimationFrame(renderFrame); }; frameRef.current = window.requestAnimationFrame(renderFrame); @@ -110,12 +120,15 @@ function useSmoothStreamingText( if (displayed === text) onComplete?.(); }, [displayed, onComplete, text]); - useEffect(() => () => { - if (frameRef.current !== null) { - window.cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } - }, []); + useEffect( + () => () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }, + [], + ); return displayed; } @@ -158,8 +171,56 @@ 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)) { + if ( + name !== "load_skill" || + args == null || + typeof args !== "object" || + Array.isArray(args) + ) { return undefined; } const skillName = (args as Record).skill_name; @@ -190,14 +251,41 @@ export function ThinkingBlock({ touched.current = true; setOpen((o) => !o); }; - const body = text.replace(/^\s+/, ""); - const displayedBody = useSmoothStreamingText(body, !done || streaming, onStreamFrame); + const body = text + .replace(/\r\n?/g, "\n") + .trimStart() + .split(/\n{2,}/) + .map((paragraph) => + paragraph.replace(/[^\S\n]*\n[^\S\n]*/g, (lineBreak, offset, source) => { + const before = source[offset - 1] ?? ""; + const after = source[offset + lineBreak.length] ?? ""; + if (!before || !after) return ""; + if (/\p{Script=Han}/u.test(before) && /\p{Script=Han}/u.test(after)) { + return ""; + } + if ( + /[(\[{“‘/]/u.test(before) || + /[),.\]},。!?;:、”’]/u.test(after) + ) { + return ""; + } + return " "; + }), + ) + .join("\n\n"); + const displayedBody = useSmoothStreamingText( + body, + !done || streaming, + onStreamFrame, + ); const { ref, onScroll } = useStickToBottom(displayedBody); return (
- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} {downloadStatus ? (

{downloadStatus.message} @@ -473,14 +572,18 @@ function DeliveryCard({ name: comparison?.target.agentName ?? value.agentName, files: comparison?.target.files ?? [], }} - comparison={comparison ? { - baseProject: { - name: comparison.base.agentName, - files: comparison.base.files ?? [], - }, - baseLabel: "优化前", - targetLabel: "优化后", - } : undefined} + comparison={ + comparison + ? { + baseProject: { + name: comparison.base.agentName, + files: comparison.base.files ?? [], + }, + baseLabel: "优化前", + targetLabel: "优化后", + } + : undefined + } open={comparisonOpen} onClose={() => setComparisonOpen(false)} onChange={() => {}} @@ -522,7 +625,10 @@ const StreamingTextBlock = memo(function StreamingTextBlock({ type PlanBlockValue = Extract; -const PLAN_STATUS_LABELS: Record = { +const PLAN_STATUS_LABELS: Record< + PlanBlockValue["items"][number]["status"], + string +> = { pending: "待处理", in_progress: "进行中", completed: "已完成", @@ -566,10 +672,14 @@ function PlanBlock({ )} {summary ? {summary} : null} {items.length > 0 ? ( - + ) : null} -

0 ? "open" : ""}`}> +
0 ? "open" : ""}`} + >
{items.length > 0 ? (
    @@ -601,13 +711,15 @@ function studioToolArtifacts(response: unknown): StudioToolArtifact[] { if (Array.isArray(record.studio_artifacts)) { candidates = record.studio_artifacts; } else if (nested && typeof nested === "object") { - const nestedArtifacts = (nested as Record).studio_artifacts; + const nestedArtifacts = (nested as Record) + .studio_artifacts; if (Array.isArray(nestedArtifacts)) candidates = nestedArtifacts; } return candidates.flatMap((candidate) => { if (!candidate || typeof candidate !== "object") return []; const artifact = candidate as Record; - return typeof artifact.name === "string" && typeof artifact.contentUrl === "string" + return typeof artifact.name === "string" && + typeof artifact.contentUrl === "string" ? [{ name: artifact.name, contentUrl: artifact.contentUrl }] : []; }); @@ -624,7 +736,9 @@ function ToolBlock({ status, defaultOpen = false, retrying = false, + codexActivity, onBranchSelect, + onAction, }: { name: string; args?: unknown; @@ -633,21 +747,27 @@ 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 - && createdAgentsHaveFailure(args, response); + const inferredCreateAgentFailure = + name === "create_agents" && + done && + createdAgentsHaveFailure(args, response); const toolStatus = inferredCreateAgentFailure ? "failed" - : status ?? (done ? "completed" : "running"); - const isAdjustingAgent = name === "create_agents" - && toolStatus === "failed" - && retrying; + : (status ?? (done ? "completed" : "running")); + const isAdjustingAgent = + name === "create_agents" && toolStatus === "failed" && retrying; 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(() => { @@ -666,7 +786,9 @@ function ToolBlock({ ? response : JSON.stringify(response, null, 2); const truncated = - respText && respText.length > 2000 ? respText.slice(0, 2000) + "\n…(已截断)" : respText; + respText && respText.length > 2000 + ? respText.slice(0, 2000) + "\n…(已截断)" + : respText; return ( )} - + ) : null} -
    +
    + {codexActivity ? ( +
    +
    + + + Codex Sandbox + + + {codexActivity.title} + +
    + +
    + {codexActivity.items.length > 0 ? ( + item.block)} + streaming={!done} + onAction={onAction} + /> + ) : ( + + 正在等待 Codex 输出 + + )} +
    +
    + ) : null} {DetailRenderer ? ( - ) :
    - {args != null && ( -
    -
    参数
    -
    {JSON.stringify(args, null, 2)}
    -
    - )} - {truncated != null && ( -
    -
    返回
    -
    {truncated}
    -
    - )} - {studioArtifacts.length > 0 && ( -
    -
    产物
    -
    - {studioArtifacts.map((artifact) => ( - - 下载 {artifact.name} - - ))} + ) : !codexActivity ? ( +
    + {args != null && ( +
    +
    参数
    +
    +                    {JSON.stringify(args, null, 2)}
    +                  
    -
    - )} -
    } + )} + {truncated != null && ( +
    +
    返回
    +
    {truncated}
    +
    + )} + {studioArtifacts.length > 0 && ( +
    +
    产物
    +
    + {studioArtifacts.map((artifact) => ( + + 下载 {artifact.name} + + ))} +
    +
    + )} +
    + ) : null}
    @@ -766,10 +928,15 @@ function ArtifactCard({ }) { const [pending, setPending] = useState(""); const [error, setError] = useState(""); - const [preview, setPreview] = useState<{ name: string; url: string } | null>(null); - useEffect(() => () => { - if (preview) URL.revokeObjectURL(preview.url); - }, [preview]); + const [preview, setPreview] = useState<{ name: string; url: string } | null>( + null, + ); + useEffect( + () => () => { + if (preview) URL.revokeObjectURL(preview.url); + }, + [preview], + ); const closePreview = () => setPreview(null); const download = async (filename: string, version: number) => { @@ -784,7 +951,11 @@ function ArtifactCard({ setPending(""); } }; - const openPreview = async (filename: string, version: number, name: string) => { + const openPreview = async ( + filename: string, + version: number, + name: string, + ) => { if (!onPreview) return; setPending(`preview:${name}`); setError(""); @@ -797,56 +968,91 @@ function ArtifactCard({ setPending(""); } }; - const files = block.files.filter((file) => !file.filename.endsWith(".preview.webp")); + const files = block.files.filter( + (file) => !file.filename.endsWith(".preview.webp"), + ); return (
    {files.map((file) => { const previewName = `${file.filename.replace(/\.pptx$/i, "")}.preview.webp`; - const previewFile = block.files.find((item) => item.filename === previewName); + const previewFile = block.files.find( + (item) => item.filename === previewName, + ); return ( -
    - - - {file.filename} - PowerPoint 演示文稿 - - - {previewFile && ( +
    + + + {file.filename} + PowerPoint 演示文稿 + + + {previewFile && ( + + )} - )} - - -
    - )})} +
    +
    + ); + })} {error &&
    {error}
    } {preview && ( -
    - +
    {`${preview.name} @@ -867,9 +1073,9 @@ function AuthCard({ block: AuthBlock; onAuth?: (block: AuthBlock) => Promise; }) { - const [status, setStatus] = useState<"idle" | "authorizing" | "done" | "error">( - block.done ? "done" : "idle", - ); + const [status, setStatus] = useState< + "idle" | "authorizing" | "done" | "error" + >(block.done ? "done" : "idle"); const [err, setErr] = useState(""); const toolLabel = block.label || "MCP 工具集"; @@ -924,11 +1130,13 @@ function AuthCard({ {toolLabel} 需要授权

    - 工具集 {toolLabel} 使用 OAuth 保护, - 需登录授权后方可调用。 + 工具集 {toolLabel} 使用 OAuth + 保护, 需登录授权后方可调用。 {provider && ( <> - {" "}将跳转至 {provider} 完成登录, + {" "} + 将跳转至 {provider}{" "} + 完成登录, )} 授权完成后对话自动继续。 @@ -977,7 +1185,9 @@ export interface BlocksProps { onDownloadDelivery?: ( delivery: Extract["value"], ) => Promise; - onDeployDelivery?: (delivery: Extract["value"]) => void; + onDeployDelivery?: ( + delivery: Extract["value"], + ) => void; onBranchSelect?: (branch: BranchCompareBranch) => void; } @@ -998,7 +1208,7 @@ export function Blocks({ onBranchSelect, }: BlocksProps) { const lastTextBlockIndex = blocks.reduce( - (lastIndex, block, index) => block.kind === "text" ? index : lastIndex, + (lastIndex, block, index) => (block.kind === "text" ? index : lastIndex), -1, ); return ( @@ -1008,9 +1218,11 @@ export function Blocks({ case "progress": return ; case "thinking": { - const answerStarted = blocks.slice(i + 1).some( - (block) => block.kind === "text" && Boolean(block.text.trim()), - ); + const answerStarted = blocks + .slice(i + 1) + .some( + (block) => block.kind === "text" && Boolean(block.text.trim()), + ); return ( ) : null; } @@ -1049,7 +1261,14 @@ export function Blocks({ case "attachment": return ; case "artifact": - return ; + return ( + + ); case "delivery": return ( ; case "tool": { if (b.name === A2UI_TOOL && b.done) return null; - const hasLaterCreateAgentAttempt = b.name === "create_agents" - && blocks.slice(i + 1).some( - (block) => block.kind === "tool" && block.name === "create_agents", - ); + const hasLaterCreateAgentAttempt = + b.name === "create_agents" && + blocks + .slice(i + 1) + .some( + (block) => + block.kind === "tool" && block.name === "create_agents", + ); return ( ); } @@ -1094,15 +1321,15 @@ export function Blocks({ return buildSurfaces(b.messages) .filter((s) => s.components[s.rootId]) .map((s) => ( - - - - )); + + + + )); default: return null; } 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} -
    -