diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index dd982960f4..8a18c7c74c 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -6,6 +6,7 @@ from typing import Any, cast from astrbot.api import logger +from astrbot.core.db import BaseDatabase from ..olayer import ( BrowserComponent, @@ -20,6 +21,7 @@ try: from shipyard_neo import BayClient from shipyard_neo.sandbox import Sandbox + from shipyard_neo.errors import NotFoundError, BayError except ImportError: logger.warning( "shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it." @@ -354,12 +356,16 @@ def __init__( endpoint_url: str, access_token: str, profile: str = "", + persist_id: str | None = None, ttl: int = 3600, + db_helper: BaseDatabase | None = None, ) -> None: self._endpoint_url = endpoint_url self._access_token = access_token self._profile = profile.strip() if profile else "" + self._persist_id = persist_id self._ttl = ttl + self._db_helper = db_helper self._client: BayClient | None = None self._sandbox: Sandbox | None = None self._bay_manager: Any = None # BayContainerManager when auto-started @@ -430,6 +436,8 @@ async def boot(self, session_id: str) -> None: access_token=self._access_token, ) await self._client.__aenter__() + + cargo_id = await self._resolve_cargo_id() # Resolve profile: user-specified > smart selection > default. # An empty profile means auto-select; any non-empty profile must be @@ -439,6 +447,7 @@ async def boot(self, session_id: str) -> None: self._sandbox = await self._client.create_sandbox( profile=resolved_profile, ttl=self._ttl, + cargo_id=cargo_id, ) # --- Readiness gate: wait until sandbox session is READY --- @@ -587,6 +596,64 @@ def _score(p: Any) -> tuple[int, int]: ) return chosen + + async def _resolve_cargo_id(self) -> str | None: + if self._persist_id is None: + return None + + if self._db_helper is None: + logger.warning( + "[Computer] persist_id is set but no db_helper provided; " + "file persistence will not work." + ) + return None + + # Check if cargo with the persist_id already exists + ret = await self._db_helper.get_shipyard_neo_persist(self._persist_id) + cargo_id: str | None = None + if ret is not None: + cargo_id = ret.cargo_id + + if cargo_id is not None: + # Detect if the cargo exists on Bay + try: + await self._client.cargos.get(cargo_id) + except NotFoundError: + logger.info( + "[Computer] No existing cargo found on Bay for persist_id=%s; " + "a new cargo will be created.", + self._persist_id, + ) + cargo_id = None + + if cargo_id is None: + # Create a new cargo and save the mapping + try: + try: + cargo = await self._client.cargos.create() + cargo_id = cargo.id + except BayError as e: + # 旧版 SDK 需要传入 size_limit_mb,暂时固定为 10GB + cargo = await self._client.cargos.create(size_limit_mb=10240) + cargo_id = cargo.id + + if cargo_id is not None: + await self._db_helper.upsert_shipyard_neo_persist(self._persist_id, cargo_id) + logger.info( + "[Computer] Created new cargo for persist_id=%s: cargo_id=%s", + self._persist_id, + cargo_id, + ) + except Exception as e: + logger.error( + "[Computer] Failed to create cargo for persist_id=%s: %s; " + "file persistence will not work.", + self._persist_id, + e, + ) + cargo_id = None + + return cargo_id async def shutdown(self, *, delete_sandbox: bool = False) -> None: if self._client is not None: diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 9be646265e..aa4c3188e8 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -599,19 +599,25 @@ async def get_booter( token = sandbox_cfg.get("shipyard_neo_access_token", "") ttl = sandbox_cfg.get("shipyard_neo_ttl", 3600) profile = sandbox_cfg.get("shipyard_neo_profile", "python-default") + persist_id = sandbox_cfg.get("shipyard_neo_persist_id", "").strip() or None + + # Inject db for persist_id lookup in ShipyardNeoBooter + db_helper = context.get_db() # Auto-discover token from Bay's credentials.json if not configured if not token: token = _discover_bay_credentials(ep) logger.info( - f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}" + f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, persist_id={persist_id}, ttl={ttl}" ) client = ShipyardNeoBooter( endpoint_url=ep, access_token=token, profile=profile, + persist_id=persist_id, ttl=ttl, + db_helper=db_helper, ) elif booter_type == "cua": from .booters.cua import CuaBooter, build_cua_booter_kwargs diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index f9a0095e9e..495a0f7ef3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -177,6 +177,7 @@ "shipyard_neo_endpoint": "", "shipyard_neo_access_token": "", "shipyard_neo_profile": "python-default", + "shipyard_neo_persist_id": "", "shipyard_neo_ttl": 3600, "cua_image": CUA_DEFAULT_CONFIG["image"], "cua_os_type": CUA_DEFAULT_CONFIG["os_type"], @@ -3404,6 +3405,15 @@ "provider_settings.sandbox.booter": "shipyard_neo", }, }, + "provider_settings.sandbox.shipyard_neo_persist_id": { + "description": "Shipyard Neo Persist ID", + "type": "string", + "hint": "具有相同 文件持久化 ID 的沙箱会复用同一个存储卷,从而实现文件持久化。适用于需要跨会话保存文件的场景。留空则不启用文件持久化。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "shipyard_neo", + }, + }, "provider_settings.sandbox.shipyard_neo_ttl": { "description": "Shipyard Neo Sandbox TTL", "type": "int", diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index bf415ffb5c..912fca39ff 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -23,6 +23,7 @@ Preference, ProviderStat, SessionProjectRelation, + ShipyardNeoPersist, Stats, UmoAlias, WebChatThread, @@ -826,6 +827,25 @@ async def get_umo_alias(self, umo: str) -> UmoAlias | None: async def get_umo_aliases(self, umos: list[str] | None = None) -> list[UmoAlias]: """Get alias metadata, optionally restricted to the given UMO list.""" ... + + # ==== + # Shipyard Neo Persist Management + # ==== + + @abc.abstractmethod + async def upsert_shipyard_neo_persist(self, persist_id: str, cargo_id: str) -> ShipyardNeoPersist: + """Create or update the persistent mapping for a Shipyard Neo cargo.""" + ... + + @abc.abstractmethod + async def get_shipyard_neo_persist(self, persist_id: str) -> ShipyardNeoPersist | None: + """Get the persistent mapping for a Shipyard Neo cargo.""" + ... + + @abc.abstractmethod + async def delete_shipyard_neo_persist(self, persist_id: str) -> None: + """Delete the persistent mapping for a Shipyard Neo cargo.""" + ... # ==== # ChatUI Project Management diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 9a297b34da..d95cabc857 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -528,6 +528,18 @@ class CommandConflict(TimestampMixin, SQLModel, table=True): ) +class ShipyardNeoPersist(TimestampMixin, SQLModel, table=True): + """Mapping persist_id to cargo_id for Shipyard Neo booters.""" + + __tablename__ = "shipyard_neo_persist" # type: ignore + + id: int | None = Field( + default=None, primary_key=True, sa_column_kwargs={"autoincrement": True} + ) + persist_id: str = Field(nullable=False, max_length=255, unique=True) + cargo_id: str = Field(nullable=False, max_length=255) + + @dataclass class Conversation: """LLM 对话类 diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index b7706cc513..efed1d52a0 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -25,6 +25,7 @@ Preference, ProviderStat, SessionProjectRelation, + ShipyardNeoPersist, SQLModel, UmoAlias, WebChatThread, @@ -1867,6 +1868,56 @@ async def get_umo_aliases(self, umos: list[str] | None = None) -> list[UmoAlias] result = await session.execute(query) return list(result.scalars().all()) + # ==== + # Shipyard Neo Persist Management + # ==== + + async def upsert_shipyard_neo_persist(self, persist_id: str, cargo_id: str) -> ShipyardNeoPersist: + """Create or update the persistent mapping for a Shipyard Neo cargo.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + result = await session.execute( + select(ShipyardNeoPersist).where( + col(ShipyardNeoPersist.persist_id) == persist_id, + ), + ) + persist = result.scalar_one_or_none() + if persist: + persist.cargo_id = cargo_id + persist.updated_at = datetime.now(timezone.utc) + else: + persist = ShipyardNeoPersist( + persist_id=persist_id, + cargo_id=cargo_id, + ) + session.add(persist) + await session.flush() + await session.refresh(persist) + return persist + + async def get_shipyard_neo_persist(self, persist_id: str) -> ShipyardNeoPersist | None: + """Get the persistent mapping for a Shipyard Neo cargo.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ShipyardNeoPersist).where( + col(ShipyardNeoPersist.persist_id) == persist_id, + ), + ) + return result.scalar_one_or_none() + + async def delete_shipyard_neo_persist(self, persist_id: str) -> None: + """Delete the persistent mapping for a Shipyard Neo cargo.""" + async with self.get_db() as session: + session: AsyncSession + await session.execute( + delete(ShipyardNeoPersist).where( + col(ShipyardNeoPersist.persist_id) == persist_id, + ), + ) + await session.commit() + # ==== # ChatUI Project Management # ==== diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 2e8a786d1c..15c611ab3b 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -182,6 +182,10 @@ "description": "Shipyard Neo Profile", "hint": "Sandbox profile for Shipyard Neo, e.g. python-default. Leave empty to auto-select the most capable profile." }, + "shipyard_neo_persist_id": { + "description": "Shipyard Neo Persist ID", + "hint": "When enabled, sandboxes with the same persist ID will reuse the same storage volume for file persistence. Suitable for scenarios that require saving files across sessions. Leave empty to disable file persistence." + }, "shipyard_neo_ttl": { "description": "Shipyard Neo Sandbox TTL", "hint": "Sandbox time-to-live in seconds." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 6f79726b33..27413cb19a 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -182,6 +182,10 @@ "description": "Профиль Shipyard Neo", "hint": "Профиль песочницы, например, python-default. Оставьте пустым для автоматического выбора самого функционального профиля." }, + "shipyard_neo_persist_id": { + "description": "Идентификатор сохранения файла Shipyard Neo", + "hint": "При включении песочницы с одинаковым идентификатором сохранения будет использоваться один и тот же том для сохранения файлов, что обеспечивает сохранение файлов между сессиями. Подходит для сценариев, требующих сохранения файлов между сессиями. Оставьте пустым, чтобы отключить сохранение файлов." + }, "shipyard_neo_ttl": { "description": "TTL песочницы Shipyard Neo", "hint": "Время жизни песочницы в секундах." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index c73fcae2d7..5ae95253b8 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -184,6 +184,10 @@ "description": "Shipyard Neo Profile", "hint": "Shipyard Neo 沙箱 profile,例如 python-default。留空时自动选择能力更完整的 profile。" }, + "shipyard_neo_persist_id": { + "description": "Shipyard Neo 文件持久化ID", + "hint": "具有相同文件持久化ID的沙箱会复用同一个存储卷,从而实现文件持久化。适用于需要跨会话保存文件的场景。留空则不启用文件持久化。" + }, "shipyard_neo_ttl": { "description": "Shipyard Neo Sandbox 存活时间(秒)", "hint": "Shipyard Neo 沙箱的生存时间(秒)。" diff --git a/tests/test_shipyard_neo_booter.py b/tests/test_shipyard_neo_booter.py index 598a2aefe7..8c8ac6960a 100644 --- a/tests/test_shipyard_neo_booter.py +++ b/tests/test_shipyard_neo_booter.py @@ -240,6 +240,7 @@ def _make_fake_context(self, booter_type: str = "shipyard_neo"): "shipyard_neo_access_token": "sk-test", "shipyard_neo_ttl": 3600, "shipyard_neo_profile": "python-default", + "shipyard_neo_persist_id": "", }, } }