diff --git a/backend/.env.example b/backend/.env.example index f5a95b5..5c3282c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,12 +3,9 @@ DATA_ROOT=../data BOTS_WORKSPACE_ROOT=../workspace/bots # Database -# SQLite (recommended): leave DATABASE_URL unset, backend will use: -# sqlite:///{DATA_ROOT}/nanobot_dashboard.db -# DATABASE_URL=sqlite:///../data/nanobot_dashboard.db -# PostgreSQL example: -# DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard -# MySQL example: +# PostgreSQL: +DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard +# MySQL: # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # Show SQL statements in backend logs (debug only). DATABASE_ECHO=true @@ -50,6 +47,8 @@ STT_DEVICE=cpu APP_HOST=0.0.0.0 APP_PORT=8000 APP_RELOAD=true +APP_LOG_LEVEL=warning +APP_ACCESS_LOG=false # Optional overrides (fallback only; usually keep empty when using template files) DEFAULT_AGENTS_MD= diff --git a/backend/api/platform_router.py b/backend/api/platform_router.py index 04289a6..7836b8d 100644 --- a/backend/api/platform_router.py +++ b/backend/api/platform_router.py @@ -1,12 +1,29 @@ -from typing import Optional +import time +import shlex +from typing import Any, Dict, Optional +import logging +import httpx from fastapi import APIRouter, Depends, HTTPException, Request -from sqlmodel import Session +from sqlmodel import Session, select +from clients.edge.errors import log_edge_failure, summarize_edge_exception +from clients.edge.http import HttpEdgeClient from core.cache import cache from core.database import get_session -from schemas.platform import PlatformSettingsPayload, SystemSettingPayload +from models.bot import BotInstance +from providers.target import ProviderTarget +from providers.selector import get_runtime_provider +from schemas.platform import ( + ManagedNodeConnectivityResult, + ManagedNodeNativePreflightResult, + ManagedNodePayload, + PlatformSettingsPayload, + SystemSettingPayload, +) +from services.node_registry_service import ManagedNode from services.platform_service import ( + build_node_resource_overview, build_platform_overview, create_or_update_system_setting, delete_system_setting, @@ -18,10 +35,284 @@ from services.platform_service import ( ) router = APIRouter() +logger = logging.getLogger(__name__) +PLATFORM_OVERVIEW_CACHE_KEY = "platform:overview" +PLATFORM_OVERVIEW_CACHE_TTL_SECONDS = 15 +PLATFORM_NODES_CACHE_KEY = "platform:nodes:list" +PLATFORM_NODES_CACHE_TTL_SECONDS = 20 + + +def _cached_platform_overview_payload() -> Optional[Dict[str, Any]]: + cached = cache.get_json(PLATFORM_OVERVIEW_CACHE_KEY) + return cached if isinstance(cached, dict) else None + + +def _store_platform_overview_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + cache.set_json(PLATFORM_OVERVIEW_CACHE_KEY, payload, ttl=PLATFORM_OVERVIEW_CACHE_TTL_SECONDS) + return payload + + +def _invalidate_platform_overview_cache() -> None: + cache.delete(PLATFORM_OVERVIEW_CACHE_KEY) + + +def _cached_platform_nodes_payload() -> Optional[Dict[str, Any]]: + cached = cache.get_json(PLATFORM_NODES_CACHE_KEY) + if not isinstance(cached, dict): + return None + items = cached.get("items") + if not isinstance(items, list): + return None + return {"items": items} + + +def _store_platform_nodes_payload(items: list[Dict[str, Any]]) -> Dict[str, Any]: + payload = {"items": items} + cache.set_json(PLATFORM_NODES_CACHE_KEY, payload, ttl=PLATFORM_NODES_CACHE_TTL_SECONDS) + return payload + + +def _invalidate_platform_nodes_cache() -> None: + cache.delete(PLATFORM_NODES_CACHE_KEY) + + +def _normalize_node_payload(payload: ManagedNodePayload) -> ManagedNodePayload: + normalized_node_id = str(payload.node_id or "").strip().lower() + if not normalized_node_id: + raise HTTPException(status_code=400, detail="node_id is required") + transport_kind = str(payload.transport_kind or "edge").strip().lower() or "edge" + if transport_kind != "edge": + raise HTTPException(status_code=400, detail="Only edge transport is supported") + runtime_kind = str(payload.runtime_kind or "docker").strip().lower() or "docker" + core_adapter = str(payload.core_adapter or "nanobot").strip().lower() or "nanobot" + native_sandbox_mode = _normalize_native_sandbox_mode(payload.native_sandbox_mode) + base_url = str(payload.base_url or "").strip() + if transport_kind == "edge" and not base_url: + raise HTTPException(status_code=400, detail="base_url is required for edge nodes") + return payload.model_copy( + update={ + "node_id": normalized_node_id, + "display_name": str(payload.display_name or normalized_node_id).strip() or normalized_node_id, + "base_url": base_url, + "auth_token": str(payload.auth_token or "").strip(), + "transport_kind": transport_kind, + "runtime_kind": runtime_kind, + "core_adapter": core_adapter, + "workspace_root": str(payload.workspace_root or "").strip(), + "native_command": str(payload.native_command or "").strip(), + "native_workdir": str(payload.native_workdir or "").strip(), + "native_sandbox_mode": native_sandbox_mode, + } + ) + + +def _normalize_native_sandbox_mode(raw_value: Any) -> str: + text = str(raw_value or "").strip().lower() + if text in {"workspace", "sandbox", "strict"}: + return "workspace" + if text in {"full_access", "full-access", "danger-full-access", "escape"}: + return "full_access" + return "inherit" + + +def _managed_node_from_payload(payload: ManagedNodePayload) -> ManagedNode: + normalized = _normalize_node_payload(payload) + return ManagedNode( + node_id=normalized.node_id, + display_name=normalized.display_name, + base_url=normalized.base_url, + enabled=bool(normalized.enabled), + auth_token=normalized.auth_token, + metadata={ + "transport_kind": normalized.transport_kind, + "runtime_kind": normalized.runtime_kind, + "core_adapter": normalized.core_adapter, + "workspace_root": normalized.workspace_root, + "native_command": normalized.native_command, + "native_workdir": normalized.native_workdir, + "native_sandbox_mode": normalized.native_sandbox_mode, + }, + ) + + +def _node_status(node: ManagedNode, *, refresh_failed: bool = False) -> str: + if not bool(node.enabled): + return "disabled" + transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower() + if transport_kind != "edge": + return "unknown" + if refresh_failed: + return "offline" + return "online" if node.last_seen_at else "unknown" + + +def _serialize_node(node: ManagedNode, *, refresh_failed: bool = False) -> Dict[str, Any]: + metadata = dict(node.metadata or {}) + return { + "node_id": node.node_id, + "display_name": node.display_name, + "base_url": node.base_url, + "enabled": bool(node.enabled), + "transport_kind": str(metadata.get("transport_kind") or ""), + "runtime_kind": str(metadata.get("runtime_kind") or ""), + "core_adapter": str(metadata.get("core_adapter") or ""), + "workspace_root": str(metadata.get("workspace_root") or ""), + "native_command": str(metadata.get("native_command") or ""), + "native_workdir": str(metadata.get("native_workdir") or ""), + "native_sandbox_mode": str(metadata.get("native_sandbox_mode") or "inherit"), + "metadata": metadata, + "capabilities": dict(node.capabilities or {}), + "resources": dict(getattr(node, "resources", {}) or {}), + "last_seen_at": node.last_seen_at, + "status": _node_status(node, refresh_failed=refresh_failed), + } + + +def _test_edge_connectivity(resolve_edge_client, node: ManagedNode) -> ManagedNodeConnectivityResult: + started = time.perf_counter() + try: + client = resolve_edge_client( + ProviderTarget( + node_id=node.node_id, + transport_kind="edge", + runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"), + core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"), + ) + ) + node_self = _edge_node_self_with_native_preflight(client=client, node=node) + latency_ms = max(1, int((time.perf_counter() - started) * 1000)) + return ManagedNodeConnectivityResult( + ok=True, + status="online", + latency_ms=latency_ms, + detail="dashboard-edge reachable", + node_self=node_self, + ) + except Exception as exc: + latency_ms = max(1, int((time.perf_counter() - started) * 1000)) + return ManagedNodeConnectivityResult( + ok=False, + status="offline", + latency_ms=latency_ms, + detail=summarize_edge_exception(exc), + node_self=None, + ) + + +def _split_native_command(raw_command: Optional[str]) -> list[str]: + text = str(raw_command or "").strip() + if not text: + return [] + try: + return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()] + except Exception: + return [text] + + +def _runtime_native_supported(node_self: Dict[str, Any]) -> bool: + capabilities = dict(node_self.get("capabilities") or {}) + runtime_caps = dict(capabilities.get("runtime") or {}) + return bool(runtime_caps.get("native") is True) + + +def _test_edge_native_preflight( + resolve_edge_client, + node: ManagedNode, + *, + native_command: Optional[str] = None, + native_workdir: Optional[str] = None, +) -> ManagedNodeNativePreflightResult: + started = time.perf_counter() + command_hint = _split_native_command(native_command) + workdir_hint = str(native_workdir or "").strip() + try: + client = resolve_edge_client( + ProviderTarget( + node_id=node.node_id, + transport_kind="edge", + runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"), + core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"), + ) + ) + node_self = dict(client.heartbeat_node() or {}) + preflight = dict( + client.preflight_native( + native_command=native_command, + native_workdir=native_workdir, + ) + or {} + ) + latency_ms = max(1, int((time.perf_counter() - started) * 1000)) + command = [str(item or "").strip() for item in list(preflight.get("command") or []) if str(item or "").strip()] + workdir = str(preflight.get("workdir") or "") + detail = str(preflight.get("detail") or "") + if not detail: + detail = "native launcher ready" if bool(preflight.get("ok")) else "native launcher not ready" + return ManagedNodeNativePreflightResult( + ok=bool(preflight.get("ok")), + status="online", + latency_ms=latency_ms, + detail=detail, + command=command, + workdir=workdir, + command_available=bool(preflight.get("command_available")), + workdir_exists=bool(preflight.get("workdir_exists")), + runtime_native_supported=_runtime_native_supported(node_self), + node_self=node_self, + ) + except Exception as exc: + latency_ms = max(1, int((time.perf_counter() - started) * 1000)) + return ManagedNodeNativePreflightResult( + ok=False, + status="offline", + latency_ms=latency_ms, + detail=summarize_edge_exception(exc), + command=command_hint, + workdir=workdir_hint, + command_available=False, + workdir_exists=False if workdir_hint else True, + runtime_native_supported=False, + node_self=None, + ) + + +def _edge_node_self_with_native_preflight(*, client: HttpEdgeClient, node: ManagedNode) -> Dict[str, Any]: + node_self = dict(client.heartbeat_node() or {}) + metadata = dict(node.metadata or {}) + native_command = str(metadata.get("native_command") or "").strip() or None + native_workdir = str(metadata.get("native_workdir") or "").strip() or None + runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower() + should_probe = bool(native_command or native_workdir or runtime_kind == "native") + if not should_probe: + return node_self + try: + preflight = dict(client.preflight_native(native_command=native_command, native_workdir=native_workdir) or {}) + except Exception as exc: + log_edge_failure( + logger, + key=f"platform-node-native-preflight:{node.node_id}", + exc=exc, + message=f"Failed to run native preflight for node_id={node.node_id}", + ) + return node_self + caps = dict(node_self.get("capabilities") or {}) + process_caps = dict(caps.get("process") or {}) + if preflight.get("command"): + process_caps["command"] = list(preflight.get("command") or []) + process_caps["available"] = bool(preflight.get("ok")) + process_caps["command_available"] = bool(preflight.get("command_available")) + process_caps["workdir_exists"] = bool(preflight.get("workdir_exists")) + process_caps["workdir"] = str(preflight.get("workdir") or "") + process_caps["detail"] = str(preflight.get("detail") or "") + caps["process"] = process_caps + node_self["capabilities"] = caps + node_self["native_preflight"] = preflight + return node_self def _apply_platform_runtime_changes(request: Request) -> None: - cache.delete_prefix("") + _invalidate_platform_overview_cache() + _invalidate_platform_nodes_cache() speech_service = getattr(request.app.state, "speech_service", None) if speech_service is not None and hasattr(speech_service, "reset_runtime"): speech_service.reset_runtime() @@ -29,8 +320,305 @@ def _apply_platform_runtime_changes(request: Request) -> None: @router.get("/api/platform/overview") def get_platform_overview(request: Request, session: Session = Depends(get_session)): - docker_manager = getattr(request.app.state, "docker_manager", None) - return build_platform_overview(session, docker_manager) + cached_payload = _cached_platform_overview_payload() + if cached_payload is not None: + return cached_payload + + def _read_runtime(bot): + provider = get_runtime_provider(request.app.state, bot) + status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper() + runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {}) + runtime.setdefault("docker_status", status) + return status, runtime + + payload = build_platform_overview(session, read_runtime=_read_runtime) + return _store_platform_overview_payload(payload) + + +@router.get("/api/platform/nodes") +def list_platform_nodes(request: Request, session: Session = Depends(get_session)): + cached_payload = _cached_platform_nodes_payload() + if cached_payload is not None: + return cached_payload + + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "list_nodes"): + return {"items": []} + resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None) + refreshed_items = [] + for node in node_registry.list_nodes(): + metadata = dict(node.metadata or {}) + refresh_failed = False + if ( + callable(resolve_edge_client) + and str(metadata.get("transport_kind") or "").strip().lower() == "edge" + and bool(node.enabled) + ): + try: + client = resolve_edge_client( + ProviderTarget( + node_id=node.node_id, + transport_kind="edge", + runtime_kind=str(metadata.get("runtime_kind") or "docker"), + core_adapter=str(metadata.get("core_adapter") or "nanobot"), + ) + ) + node_self = _edge_node_self_with_native_preflight(client=client, node=node) + node = node_registry.mark_node_seen( + session, + node_id=node.node_id, + display_name=str(node.display_name or node_self.get("display_name") or node.node_id), + capabilities=dict(node_self.get("capabilities") or {}), + resources=dict(node_self.get("resources") or {}), + ) + except Exception as exc: + refresh_failed = True + log_edge_failure( + logger, + key=f"platform-node-refresh:{node.node_id}", + exc=exc, + message=f"Failed to refresh edge node metadata for node_id={node.node_id}", + ) + refreshed_items.append((node, refresh_failed)) + items = [] + for node, refresh_failed in refreshed_items: + items.append(_serialize_node(node, refresh_failed=refresh_failed)) + return _store_platform_nodes_payload(items) + + +@router.get("/api/platform/nodes/{node_id}") +def get_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)): + normalized_node_id = str(node_id or "").strip().lower() + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "get_node"): + raise HTTPException(status_code=500, detail="node registry is unavailable") + node = node_registry.get_node(normalized_node_id) + if node is None: + raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}") + return _serialize_node(node) + + +@router.post("/api/platform/nodes") +def create_platform_node(payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)): + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "get_node"): + raise HTTPException(status_code=500, detail="node registry is unavailable") + normalized = _normalize_node_payload(payload) + if node_registry.get_node(normalized.node_id) is not None: + raise HTTPException(status_code=409, detail=f"Node already exists: {normalized.node_id}") + node = node_registry.upsert_node(session, _managed_node_from_payload(normalized)) + _invalidate_platform_overview_cache() + _invalidate_platform_nodes_cache() + return _serialize_node(node) + + +@router.put("/api/platform/nodes/{node_id}") +def update_platform_node(node_id: str, payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)): + normalized_node_id = str(node_id or "").strip().lower() + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "get_node"): + raise HTTPException(status_code=500, detail="node registry is unavailable") + existing = node_registry.get_node(normalized_node_id) + if existing is None: + raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}") + normalized = _normalize_node_payload(payload) + if normalized.node_id != normalized_node_id: + raise HTTPException(status_code=400, detail="node_id cannot be changed") + node = node_registry.upsert_node( + session, + ManagedNode( + node_id=normalized_node_id, + display_name=normalized.display_name, + base_url=normalized.base_url, + enabled=bool(normalized.enabled), + auth_token=normalized.auth_token or existing.auth_token, + metadata={ + "transport_kind": normalized.transport_kind, + "runtime_kind": normalized.runtime_kind, + "core_adapter": normalized.core_adapter, + "workspace_root": normalized.workspace_root, + "native_command": normalized.native_command, + "native_workdir": normalized.native_workdir, + "native_sandbox_mode": normalized.native_sandbox_mode, + }, + capabilities=dict(existing.capabilities or {}), + resources=dict(existing.resources or {}), + last_seen_at=existing.last_seen_at, + ), + ) + _invalidate_platform_overview_cache() + _invalidate_platform_nodes_cache() + return _serialize_node(node) + + +@router.delete("/api/platform/nodes/{node_id}") +def delete_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)): + normalized_node_id = str(node_id or "").strip().lower() + if normalized_node_id == "local": + raise HTTPException(status_code=400, detail="Local node cannot be deleted") + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "get_node"): + raise HTTPException(status_code=500, detail="node registry is unavailable") + if node_registry.get_node(normalized_node_id) is None: + raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}") + attached_bot_ids = session.exec(select(BotInstance.id).where(BotInstance.node_id == normalized_node_id)).all() + if attached_bot_ids: + raise HTTPException( + status_code=400, + detail=f"Node {normalized_node_id} still has bots assigned: {', '.join(str(item) for item in attached_bot_ids[:5])}", + ) + node_registry.delete_node(session, normalized_node_id) + _invalidate_platform_overview_cache() + _invalidate_platform_nodes_cache() + return {"status": "deleted", "node_id": normalized_node_id} + + +@router.post("/api/platform/nodes/test") +def test_platform_node(payload: ManagedNodePayload, request: Request): + normalized = _normalize_node_payload(payload) + temp_node = _managed_node_from_payload(normalized) + result = _test_edge_connectivity( + lambda _target: HttpEdgeClient( + node=temp_node, + http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False), + async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False), + ), + temp_node, + ) + return result.model_dump() + + +@router.post("/api/platform/nodes/native/preflight") +def test_platform_node_native_preflight(payload: ManagedNodePayload, request: Request): + normalized = _normalize_node_payload(payload) + temp_node = _managed_node_from_payload(normalized) + result = _test_edge_native_preflight( + lambda _target: HttpEdgeClient( + node=temp_node, + http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False), + async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False), + ), + temp_node, + native_command=str(normalized.native_command or "").strip() or None, + native_workdir=str(normalized.native_workdir or "").strip() or None, + ) + return result.model_dump() + + +@router.post("/api/platform/nodes/{node_id}/test") +def test_saved_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)): + normalized_node_id = str(node_id or "").strip().lower() + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "get_node"): + raise HTTPException(status_code=500, detail="node registry is unavailable") + node = node_registry.get_node(normalized_node_id) + if node is None: + raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}") + transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower() + if transport_kind != "edge": + _invalidate_platform_nodes_cache() + raise HTTPException(status_code=400, detail="Only edge transport is supported") + result = _test_edge_connectivity( + lambda target: HttpEdgeClient( + node=node, + http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False), + async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False), + ), + node, + ) + if result.ok: + node_registry.mark_node_seen( + session, + node_id=node.node_id, + display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id) if result.node_self else node.display_name, + capabilities=dict(result.node_self.get("capabilities") or {}) if result.node_self else dict(node.capabilities or {}), + resources=dict(result.node_self.get("resources") or {}) if result.node_self else dict(getattr(node, "resources", {}) or {}), + ) + _invalidate_platform_nodes_cache() + return result.model_dump() + + +@router.post("/api/platform/nodes/{node_id}/native/preflight") +def test_saved_platform_node_native_preflight(node_id: str, request: Request, session: Session = Depends(get_session)): + normalized_node_id = str(node_id or "").strip().lower() + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is None or not hasattr(node_registry, "get_node"): + raise HTTPException(status_code=500, detail="node registry is unavailable") + node = node_registry.get_node(normalized_node_id) + if node is None: + raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}") + transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower() + if transport_kind != "edge": + _invalidate_platform_nodes_cache() + raise HTTPException(status_code=400, detail="Only edge transport is supported") + metadata = dict(node.metadata or {}) + result = _test_edge_native_preflight( + lambda _target: HttpEdgeClient( + node=node, + http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False), + async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False), + ), + node, + native_command=str(metadata.get("native_command") or "").strip() or None, + native_workdir=str(metadata.get("native_workdir") or "").strip() or None, + ) + if result.status == "online" and result.node_self: + node_registry.mark_node_seen( + session, + node_id=node.node_id, + display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id), + capabilities=dict(result.node_self.get("capabilities") or {}), + resources=dict(result.node_self.get("resources") or {}), + ) + _invalidate_platform_nodes_cache() + return result.model_dump() + + +@router.get("/api/platform/nodes/{node_id}/resources") +def get_platform_node_resources(node_id: str, request: Request, session: Session = Depends(get_session)): + normalized_node_id = str(node_id or "").strip().lower() + node_registry = getattr(request.app.state, "node_registry_service", None) + if node_registry is not None and hasattr(node_registry, "get_node"): + node = node_registry.get_node(normalized_node_id) + if node is not None: + metadata = dict(getattr(node, "metadata", {}) or {}) + if str(metadata.get("transport_kind") or "").strip().lower() == "edge": + resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None) + if callable(resolve_edge_client): + from providers.target import ProviderTarget + + base = build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=None) + client = resolve_edge_client( + ProviderTarget( + node_id=normalized_node_id, + transport_kind="edge", + runtime_kind=str(metadata.get("runtime_kind") or "docker"), + core_adapter=str(metadata.get("core_adapter") or "nanobot"), + ) + ) + try: + resource_report = dict(client.get_node_resources() or {}) + except Exception as exc: + log_edge_failure( + logger, + key=f"platform-node-resources:{normalized_node_id}", + exc=exc, + message=f"Failed to load edge node resources for node_id={normalized_node_id}", + ) + return base + base["resources"] = dict(resource_report.get("resources") or resource_report) + if resource_report: + base["node_report"] = resource_report + return base + + def _read_runtime(bot): + provider = get_runtime_provider(request.app.state, bot) + status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper() + runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {}) + runtime.setdefault("docker_status", status) + return status, runtime + + return build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=_read_runtime) @router.get("/api/platform/settings") @@ -47,7 +635,8 @@ def update_platform_settings_api(payload: PlatformSettingsPayload, request: Requ @router.post("/api/platform/cache/clear") def clear_platform_cache(): - cache.delete_prefix("") + _invalidate_platform_overview_cache() + _invalidate_platform_nodes_cache() return {"status": "cleared"} diff --git a/backend/clients/edge/__init__.py b/backend/clients/edge/__init__.py new file mode 100644 index 0000000..5729c43 --- /dev/null +++ b/backend/clients/edge/__init__.py @@ -0,0 +1 @@ +# Client package for dashboard-edge integrations. diff --git a/backend/clients/edge/base.py b/backend/clients/edge/base.py new file mode 100644 index 0000000..8ac94c8 --- /dev/null +++ b/backend/clients/edge/base.py @@ -0,0 +1,147 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from fastapi import Request, UploadFile +from fastapi.responses import Response + +from models.bot import BotInstance + + +class EdgeClient(ABC): + @abstractmethod + async def start_bot(self, *, bot: BotInstance, start_payload: Dict[str, Any]) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def stop_bot(self, *, bot: BotInstance) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def deliver_command(self, *, bot_id: str, command: str, media: Optional[List[str]] = None) -> Optional[str]: + raise NotImplementedError + + @abstractmethod + def get_recent_logs(self, *, bot_id: str, tail: int = 300) -> List[str]: + raise NotImplementedError + + @abstractmethod + def ensure_monitor(self, *, bot_id: str) -> bool: + raise NotImplementedError + + @abstractmethod + def get_monitor_packets(self, *, bot_id: str, after_seq: int = 0, limit: int = 200) -> List[Dict[str, Any]]: + raise NotImplementedError + + @abstractmethod + def get_runtime_status(self, *, bot_id: str) -> str: + raise NotImplementedError + + @abstractmethod + def get_resource_snapshot(self, *, bot_id: str) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def get_node_resources(self) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def get_node_self(self) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def preflight_native(self, *, native_command: Optional[str] = None, native_workdir: Optional[str] = None) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def read_state( + self, + *, + bot_id: str, + state_key: str, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def write_state( + self, + *, + bot_id: str, + state_key: str, + data: Dict[str, Any], + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def sync_bot_workspace( + self, + *, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + raise NotImplementedError + + @abstractmethod + def purge_workspace(self, *, bot_id: str, workspace_root: Optional[str] = None) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def list_tree( + self, + *, + bot_id: str, + path: Optional[str] = None, + recursive: bool = False, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def read_file( + self, + *, + bot_id: str, + path: str, + max_bytes: int = 200000, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def write_markdown( + self, + *, + bot_id: str, + path: str, + content: str, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def upload_files( + self, + *, + bot_id: str, + files: List[UploadFile], + path: Optional[str] = None, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def serve_file( + self, + *, + bot_id: str, + path: str, + download: bool, + request: Request, + public: bool = False, + redirect_html_to_raw: bool = False, + workspace_root: Optional[str] = None, + ) -> Response: + raise NotImplementedError diff --git a/backend/clients/edge/errors.py b/backend/clients/edge/errors.py new file mode 100644 index 0000000..59df709 --- /dev/null +++ b/backend/clients/edge/errors.py @@ -0,0 +1,84 @@ +import logging +import threading +import time +from typing import Any + +import httpx +from fastapi import HTTPException + +_OFFLINE_LOG_LOCK = threading.Lock() +_OFFLINE_LOGGED_AT: dict[str, float] = {} +_DEFAULT_LOG_COOLDOWN_SECONDS = 60.0 + + +def describe_edge_node(node: Any) -> str: + display_name = str(getattr(node, "display_name", "") or "").strip() + node_id = str(getattr(node, "node_id", "") or "").strip() + if display_name and node_id and display_name != node_id: + return f"{display_name} ({node_id})" + return display_name or node_id or "unknown edge node" + + +def summarize_edge_exception(exc: Exception) -> str: + detail = getattr(exc, "detail", None) + text = str(detail if detail is not None else exc).strip() + if not text: + return exc.__class__.__name__ + return text[:400] + + +def edge_transport_http_exception(exc: httpx.RequestError, *, node: Any) -> HTTPException: + node_label = describe_edge_node(node) + if isinstance(exc, httpx.TimeoutException): + detail = f"dashboard-edge timed out for node {node_label}" + else: + reason = str(exc).strip() or exc.__class__.__name__ + detail = f"dashboard-edge is unreachable for node {node_label}: {reason}" + return HTTPException(status_code=502, detail=detail[:400]) + + +def is_expected_edge_offline_error(exc: Exception) -> bool: + if isinstance(exc, httpx.RequestError): + return True + if not isinstance(exc, HTTPException): + return False + if int(getattr(exc, "status_code", 0) or 0) not in {502, 503, 504}: + return False + detail = summarize_edge_exception(exc).lower() + markers = ( + "dashboard-edge is unreachable", + "dashboard-edge timed out", + "connection refused", + "request failed before receiving a response", + "name or service not known", + "nodename nor servname provided", + "temporary failure in name resolution", + ) + return any(marker in detail for marker in markers) + + +def log_edge_failure( + logger: logging.Logger, + *, + key: str, + exc: Exception, + message: str, + cooldown_seconds: float = _DEFAULT_LOG_COOLDOWN_SECONDS, +) -> None: + detail = summarize_edge_exception(exc) + if is_expected_edge_offline_error(exc): + if _should_emit_offline_log(key=key, cooldown_seconds=cooldown_seconds): + logger.info("%s detail=%s", message, detail) + return + logger.exception("%s detail=%s", message, detail) + + +def _should_emit_offline_log(*, key: str, cooldown_seconds: float) -> bool: + now = time.monotonic() + normalized_key = str(key or "edge-offline").strip() or "edge-offline" + with _OFFLINE_LOG_LOCK: + last_logged_at = _OFFLINE_LOGGED_AT.get(normalized_key, 0.0) + if now - last_logged_at < max(1.0, float(cooldown_seconds or _DEFAULT_LOG_COOLDOWN_SECONDS)): + return False + _OFFLINE_LOGGED_AT[normalized_key] = now + return True diff --git a/backend/clients/edge/http.py b/backend/clients/edge/http.py new file mode 100644 index 0000000..2a5377e --- /dev/null +++ b/backend/clients/edge/http.py @@ -0,0 +1,455 @@ +import mimetypes +from typing import Any, Callable, Dict, List, Optional +from urllib.parse import quote + +import httpx + +from fastapi import HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse, Response + +from clients.edge.base import EdgeClient +from clients.edge.errors import edge_transport_http_exception +from models.bot import BotInstance +from schemas.edge import ( + EdgeCommandRequest, + EdgeLogsResponse, + EdgeNativePreflightRequest, + EdgeNativePreflightResponse, + EdgeNodeHeartbeatResponse, + EdgeMonitorPacketsResponse, + EdgeMarkdownWriteRequest, + EdgeMonitorEnsureResponse, + EdgeNodeResourcesResponse, + EdgeNodeSelfResponse, + EdgeStateResponse, + EdgeStateWriteRequest, + EdgeStartBotRequest, + EdgeStatusResponse, + EdgeWorkspaceSyncRequest, +) +from services.node_registry_service import ManagedNode + +EDGE_AUTH_HEADER = "x-dashboard-edge-token" + + +class HttpEdgeClient(EdgeClient): + def __init__( + self, + *, + node: ManagedNode, + http_client_factory: Optional[Callable[[], httpx.Client]] = None, + async_http_client_factory: Optional[Callable[[], httpx.AsyncClient]] = None, + ) -> None: + self._node = node + self._http_client_factory = http_client_factory or (lambda: httpx.Client(timeout=15.0, trust_env=False)) + self._async_http_client_factory = async_http_client_factory or ( + lambda: httpx.AsyncClient(timeout=15.0, trust_env=False) + ) + + async def start_bot(self, *, bot: BotInstance, start_payload: Dict[str, Any]) -> Dict[str, Any]: + payload = await self._async_request_json( + "POST", + f"/api/edge/bots/{bot.id}/start", + json=EdgeStartBotRequest.model_validate(start_payload).model_dump(), + ) + return EdgeStatusResponse.model_validate(payload).model_dump() + + def stop_bot(self, *, bot: BotInstance) -> Dict[str, Any]: + payload = self._request_json("POST", f"/api/edge/bots/{bot.id}/stop") + return EdgeStatusResponse.model_validate(payload).model_dump() + + def deliver_command(self, *, bot_id: str, command: str, media: Optional[List[str]] = None) -> Optional[str]: + self._request_json( + "POST", + f"/api/edge/bots/{bot_id}/command", + json=EdgeCommandRequest(command=command, media=list(media or [])).model_dump(), + ) + return None + + def get_recent_logs(self, *, bot_id: str, tail: int = 300) -> List[str]: + payload = self._request_json( + "GET", + f"/api/edge/bots/{bot_id}/logs", + params={"tail": max(1, int(tail or 300))}, + ) + return EdgeLogsResponse.model_validate(payload).logs + + def ensure_monitor(self, *, bot_id: str) -> bool: + payload = self._request_json("POST", f"/api/edge/bots/{bot_id}/monitor/ensure") + return bool(EdgeMonitorEnsureResponse.model_validate(payload).ensured) + + def get_monitor_packets(self, *, bot_id: str, after_seq: int = 0, limit: int = 200) -> List[Dict[str, Any]]: + payload = self._request_json( + "GET", + f"/api/edge/bots/{bot_id}/monitor/packets", + params={"after_seq": max(0, int(after_seq or 0)), "limit": max(1, int(limit or 200))}, + ) + parsed = EdgeMonitorPacketsResponse.model_validate(payload) + rows: List[Dict[str, Any]] = [] + for item in parsed.packets or []: + rows.append(item.model_dump()) + return rows + + def get_runtime_status(self, *, bot_id: str) -> str: + payload = self._request_json("GET", f"/api/edge/bots/{bot_id}/runtime/status") + return str(payload.get("status") or "STOPPED").upper() + + def get_resource_snapshot(self, *, bot_id: str) -> Dict[str, Any]: + return self._request_json("GET", f"/api/edge/bots/{bot_id}/resources") + + def get_node_resources(self) -> Dict[str, Any]: + payload = self._request_json("GET", "/api/edge/node/resources") + return EdgeNodeResourcesResponse.model_validate(payload).model_dump() + + def get_node_self(self) -> Dict[str, Any]: + payload = self._request_json("GET", "/api/edge/node/self") + return EdgeNodeSelfResponse.model_validate(payload).model_dump() + + def heartbeat_node(self) -> Dict[str, Any]: + payload = self._request_json("POST", "/api/edge/node/heartbeat") + return EdgeNodeHeartbeatResponse.model_validate(payload).model_dump() + + def preflight_native(self, *, native_command: Optional[str] = None, native_workdir: Optional[str] = None) -> Dict[str, Any]: + payload = self._request_json( + "POST", + "/api/edge/runtime/native/preflight", + json=EdgeNativePreflightRequest( + native_command=str(native_command or "").strip() or None, + native_workdir=str(native_workdir or "").strip() or None, + ).model_dump(), + ) + return EdgeNativePreflightResponse.model_validate(payload).model_dump() + + def read_state( + self, + *, + bot_id: str, + state_key: str, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if workspace_root: + params["workspace_root"] = str(workspace_root).strip() + payload = self._request_json( + "GET", + f"/api/edge/bots/{bot_id}/state/{state_key}", + params=params or None, + ) + return EdgeStateResponse.model_validate(payload).model_dump() + + def write_state( + self, + *, + bot_id: str, + state_key: str, + data: Dict[str, Any], + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + payload = self._request_json( + "PUT", + f"/api/edge/bots/{bot_id}/state/{state_key}", + json=EdgeStateWriteRequest( + data=dict(data or {}), + workspace_root=str(workspace_root or "").strip() or None, + ).model_dump(), + ) + return EdgeStateResponse.model_validate(payload).model_dump() + + def sync_bot_workspace( + self, + *, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + self._request_json( + "POST", + f"/api/edge/bots/{bot_id}/workspace/sync", + json=EdgeWorkspaceSyncRequest( + channels_override=channels_override, + global_delivery_override=global_delivery_override, + runtime_overrides=runtime_overrides, + ).model_dump(), + ) + + def purge_workspace(self, *, bot_id: str, workspace_root: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if workspace_root: + params["workspace_root"] = str(workspace_root).strip() + payload = self._request_json( + "POST", + f"/api/edge/bots/{bot_id}/workspace/purge", + params=params or None, + ) + return EdgeStatusResponse.model_validate(payload).model_dump() + + def list_tree( + self, + *, + bot_id: str, + path: Optional[str] = None, + recursive: bool = False, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {"recursive": bool(recursive)} + if path: + params["path"] = path + if workspace_root: + params["workspace_root"] = str(workspace_root).strip() + return self._request_json("GET", f"/api/edge/bots/{bot_id}/workspace/tree", params=params) + + def read_file( + self, + *, + bot_id: str, + path: str, + max_bytes: int = 200000, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {"path": path, "max_bytes": max(4096, int(max_bytes or 200000))} + if workspace_root: + params["workspace_root"] = str(workspace_root).strip() + return self._request_json( + "GET", + f"/api/edge/bots/{bot_id}/workspace/file", + params=params, + ) + + def write_markdown( + self, + *, + bot_id: str, + path: str, + content: str, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {"path": path} + if workspace_root: + params["workspace_root"] = str(workspace_root).strip() + return self._request_json( + "PUT", + f"/api/edge/bots/{bot_id}/workspace/file/markdown", + params=params, + json=EdgeMarkdownWriteRequest(content=str(content or "")).model_dump(), + ) + + async def upload_files( + self, + *, + bot_id: str, + files: List[UploadFile], + path: Optional[str] = None, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + base_url = self._require_base_url() + multipart_files = [] + response: httpx.Response | None = None + try: + async with self._async_http_client_factory() as client: + for upload in files: + await upload.seek(0) + multipart_files.append( + ( + "files", + ( + upload.filename or "upload.bin", + upload.file, + upload.content_type or "application/octet-stream", + ), + ) + ) + response = await client.request( + method="POST", + url=f"{base_url}/api/edge/bots/{quote(bot_id, safe='')}/workspace/upload", + headers=self._headers(), + params=self._workspace_upload_params(path=path, workspace_root=workspace_root), + files=multipart_files, + ) + except httpx.RequestError as exc: + raise edge_transport_http_exception(exc, node=self._node) from exc + finally: + for upload in files: + await upload.close() + if response is None: + raise HTTPException(status_code=502, detail="dashboard-edge upload request failed before receiving a response") + return self._parse_json_response(response) + + def serve_file( + self, + *, + bot_id: str, + path: str, + download: bool, + request: Request, + public: bool = False, + redirect_html_to_raw: bool = False, + workspace_root: Optional[str] = None, + ) -> Response: + media_type, _ = mimetypes.guess_type(path) + if redirect_html_to_raw and not download and str(media_type or "").startswith("text/html"): + raw_url = self._build_dashboard_raw_url(bot_id=bot_id, path=path, public=public) + if raw_url: + return RedirectResponse(url=raw_url, status_code=307) + + base_url = self._require_base_url() + url = self._build_edge_file_url( + bot_id=bot_id, + path=path, + download=download, + raw=not redirect_html_to_raw, + workspace_root=workspace_root, + ) + headers = self._headers() + range_header = request.headers.get("range", "").strip() + if range_header and not download: + headers["range"] = range_header + try: + with self._http_client_factory() as client: + response = client.request( + method="GET", + url=f"{base_url}{url}", + headers=headers, + ) + except httpx.RequestError as exc: + raise edge_transport_http_exception(exc, node=self._node) from exc + self._raise_for_status(response) + return Response( + content=response.content, + status_code=response.status_code, + media_type=response.headers.get("content-type") or "application/octet-stream", + headers=self._response_proxy_headers(response), + ) + + def _request_json( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + base_url = self._require_base_url() + try: + with self._http_client_factory() as client: + response = client.request( + method=method.upper(), + url=f"{base_url}{path}", + headers=self._headers(), + params=params, + json=json, + ) + except httpx.RequestError as exc: + raise edge_transport_http_exception(exc, node=self._node) from exc + return self._parse_json_response(response) + + async def _async_request_json( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + base_url = self._require_base_url() + try: + async with self._async_http_client_factory() as client: + response = await client.request( + method=method.upper(), + url=f"{base_url}{path}", + headers=self._headers(), + params=params, + json=json, + ) + except httpx.RequestError as exc: + raise edge_transport_http_exception(exc, node=self._node) from exc + return self._parse_json_response(response) + + def _headers(self) -> Dict[str, str]: + headers = {"accept": "application/json"} + token = str(self._node.auth_token or "").strip() + if token: + headers[EDGE_AUTH_HEADER] = token + return headers + + def _require_base_url(self) -> str: + base_url = str(self._node.base_url or "").strip().rstrip("/") + if not base_url: + raise self._not_implemented("connect to node") + return base_url + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text.strip() or str(exc) + raise HTTPException(status_code=502, detail=f"dashboard-edge request failed: {detail[:400]}") from exc + + @classmethod + def _parse_json_response(cls, response: httpx.Response) -> Dict[str, Any]: + cls._raise_for_status(response) + try: + payload = response.json() + except Exception as exc: + raise HTTPException(status_code=502, detail="dashboard-edge returned invalid JSON") from exc + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="dashboard-edge returned unexpected payload") + return payload + + @staticmethod + def _build_dashboard_raw_url(bot_id: str, path: str, public: bool) -> str: + normalized = "/".join(part for part in str(path or "").strip().split("/") if part) + if not normalized: + return "" + prefix = "/public" if public else "/api" + return f"{prefix}/bots/{quote(bot_id, safe='')}/workspace/raw/{quote(normalized, safe='/')}" + + @staticmethod + def _build_edge_file_url( + *, + bot_id: str, + path: str, + download: bool, + raw: bool, + workspace_root: Optional[str] = None, + ) -> str: + workspace_root_qs = "" + normalized_workspace_root = str(workspace_root or "").strip() + if normalized_workspace_root: + workspace_root_qs = f"&workspace_root={quote(normalized_workspace_root, safe='/')}" + if raw: + normalized = "/".join(part for part in str(path or "").strip().split("/") if part) + if not normalized: + raise HTTPException(status_code=400, detail="invalid workspace path") + return ( + f"/api/edge/bots/{quote(bot_id, safe='')}/workspace/raw/" + f"{quote(normalized, safe='/')}?download={'true' if download else 'false'}{workspace_root_qs}" + ) + return ( + f"/api/edge/bots/{quote(bot_id, safe='')}/workspace/download" + f"?path={quote(str(path or ''), safe='/')}&download={'true' if download else 'false'}{workspace_root_qs}" + ) + + @staticmethod + def _workspace_upload_params(*, path: Optional[str], workspace_root: Optional[str]) -> Optional[Dict[str, Any]]: + params: Dict[str, Any] = {} + if path: + params["path"] = path + normalized_workspace_root = str(workspace_root or "").strip() + if normalized_workspace_root: + params["workspace_root"] = normalized_workspace_root + return params or None + + @staticmethod + def _response_proxy_headers(response: httpx.Response) -> Dict[str, str]: + kept: Dict[str, str] = {} + for name in ("accept-ranges", "content-disposition", "content-length", "content-range", "cache-control"): + value = response.headers.get(name) + if value: + kept[name] = value + return kept + + def _not_implemented(self, capability: str) -> HTTPException: + node_label = self._node.display_name or self._node.node_id + return HTTPException(status_code=501, detail=f"dashboard-edge {capability} is not implemented yet for node {node_label}") diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index c9e9c1a..b2c1610 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -37,6 +37,7 @@ class BotConfigManager: "qwen": "dashscope", "aliyun-qwen": "dashscope", "moonshot": "kimi", + "vllm": "openai", # Xunfei Spark provides OpenAI-compatible endpoint. "xunfei": "openai", "iflytek": "openai", @@ -71,6 +72,11 @@ class BotConfigManager: existing_tools = existing_config.get("tools") tools_cfg: Dict[str, Any] = dict(existing_tools) if isinstance(existing_tools, dict) else {} + native_sandbox_mode = self._normalize_native_sandbox_mode(bot_data.get("native_sandbox_mode")) + if native_sandbox_mode == "workspace": + tools_cfg["restrictToWorkspace"] = True + elif native_sandbox_mode == "full_access": + tools_cfg["restrictToWorkspace"] = False if "mcp_servers" in bot_data: mcp_servers = bot_data.get("mcp_servers") if isinstance(mcp_servers, dict): @@ -249,3 +255,12 @@ class BotConfigManager: if not rows: return ["*"] return rows + + @staticmethod + def _normalize_native_sandbox_mode(raw_value: Any) -> str: + text = str(raw_value or "").strip().lower() + if text in {"workspace", "sandbox", "strict"}: + return "workspace" + if text in {"full_access", "full-access", "danger-full-access", "escape"}: + return "full_access" + return "inherit" diff --git a/backend/core/database.py b/backend/core/database.py index 5656174..9ee8fc5 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -20,18 +20,15 @@ from models import topic as _topic_models # noqa: F401 _engine_kwargs = { "echo": DATABASE_ECHO, } -if DATABASE_ENGINE == "sqlite": - _engine_kwargs["connect_args"] = {"check_same_thread": False} -else: - _engine_kwargs.update( - { - "pool_pre_ping": True, - "pool_size": DATABASE_POOL_SIZE, - "max_overflow": DATABASE_MAX_OVERFLOW, - "pool_timeout": DATABASE_POOL_TIMEOUT, - "pool_recycle": DATABASE_POOL_RECYCLE, - } - ) +_engine_kwargs.update( + { + "pool_pre_ping": True, + "pool_size": DATABASE_POOL_SIZE, + "max_overflow": DATABASE_MAX_OVERFLOW, + "pool_timeout": DATABASE_POOL_TIMEOUT, + "pool_recycle": DATABASE_POOL_RECYCLE, + } +) engine = create_engine(DATABASE_URL, **_engine_kwargs) @@ -41,6 +38,7 @@ BOT_IMAGE_TABLE = "bot_image" BOT_REQUEST_USAGE_TABLE = "bot_request_usage" BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event" SYS_SETTING_TABLE = "sys_setting" +MANAGED_NODE_TABLE = "managed_node" POSTGRES_MIGRATION_LOCK_KEY = 2026031801 MYSQL_MIGRATION_LOCK_NAME = "dashboard_nanobot_schema_migration" LEGACY_TABLE_PAIRS = [ @@ -266,30 +264,41 @@ def _ensure_botinstance_columns() -> None: dialect = engine.dialect.name required_columns = { "current_state": { - "sqlite": "TEXT DEFAULT 'IDLE'", "postgresql": "TEXT DEFAULT 'IDLE'", "mysql": "VARCHAR(64) DEFAULT 'IDLE'", }, "last_action": { - "sqlite": "TEXT", "postgresql": "TEXT", "mysql": "LONGTEXT", }, "image_tag": { - "sqlite": "TEXT DEFAULT 'nanobot-base:v0.1.4'", "postgresql": "TEXT DEFAULT 'nanobot-base:v0.1.4'", "mysql": "VARCHAR(255) DEFAULT 'nanobot-base:v0.1.4'", }, "access_password": { - "sqlite": "TEXT DEFAULT ''", "postgresql": "TEXT DEFAULT ''", "mysql": "VARCHAR(255) DEFAULT ''", }, "enabled": { - "sqlite": "INTEGER NOT NULL DEFAULT 1", "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", }, + "node_id": { + "postgresql": "TEXT NOT NULL DEFAULT 'local'", + "mysql": "VARCHAR(120) NOT NULL DEFAULT 'local'", + }, + "transport_kind": { + "postgresql": "TEXT NOT NULL DEFAULT 'direct'", + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'direct'", + }, + "runtime_kind": { + "postgresql": "TEXT NOT NULL DEFAULT 'docker'", + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'docker'", + }, + "core_adapter": { + "postgresql": "TEXT NOT NULL DEFAULT 'nanobot'", + "mysql": "VARCHAR(64) NOT NULL DEFAULT 'nanobot'", + }, } inspector = inspect(engine) @@ -304,13 +313,36 @@ def _ensure_botinstance_columns() -> None: for col, ddl_map in required_columns.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") + ddl = ddl_map.get(dialect) or ddl_map.get("postgresql") conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}")) if "enabled" in existing: - if dialect == "sqlite": - conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = 1 WHERE enabled IS NULL")) - else: - conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL")) + conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL")) + conn.commit() + + +def _ensure_botinstance_indexes() -> None: + required_indexes = [ + ("idx_bot_instance_enabled", BOT_INSTANCE_TABLE, ["enabled"]), + ("idx_bot_instance_docker_status", BOT_INSTANCE_TABLE, ["docker_status"]), + ("idx_bot_instance_node_id", BOT_INSTANCE_TABLE, ["node_id"]), + ("idx_bot_instance_transport_kind", BOT_INSTANCE_TABLE, ["transport_kind"]), + ("idx_bot_instance_runtime_kind", BOT_INSTANCE_TABLE, ["runtime_kind"]), + ("idx_bot_instance_core_adapter", BOT_INSTANCE_TABLE, ["core_adapter"]), + ("idx_bot_instance_node_transport_runtime", BOT_INSTANCE_TABLE, ["node_id", "transport_kind", "runtime_kind"]), + ] + inspector = inspect(engine) + with engine.connect() as conn: + if not inspector.has_table(BOT_INSTANCE_TABLE): + return + existing = { + str(item.get("name")) + for item in inspector.get_indexes(BOT_INSTANCE_TABLE) + if item.get("name") + } + for name, table_name, columns in required_indexes: + if name in existing: + continue + conn.execute(text(f"CREATE INDEX {name} ON {table_name} ({', '.join(columns)})")) conn.commit() @@ -350,8 +382,6 @@ def _drop_legacy_botinstance_columns() -> None: try: if engine.dialect.name == "mysql": conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN `{col}`")) - elif engine.dialect.name == "sqlite": - conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN "{col}"')) else: conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN IF EXISTS "{col}"')) except Exception: @@ -360,24 +390,6 @@ def _drop_legacy_botinstance_columns() -> None: conn.commit() -def _ensure_botmessage_columns() -> None: - if engine.dialect.name != "sqlite": - return - required_columns = { - "media_json": "TEXT", - "feedback": "TEXT", - "feedback_at": "DATETIME", - } - with engine.connect() as conn: - existing_rows = conn.execute(text(f"PRAGMA table_info({BOT_MESSAGE_TABLE})")).fetchall() - existing = {str(row[1]) for row in existing_rows} - for col, ddl in required_columns.items(): - if col in existing: - continue - conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}")) - conn.commit() - - def _drop_legacy_skill_tables() -> None: """Drop deprecated skill registry tables (moved to workspace filesystem mode).""" with engine.connect() as conn: @@ -390,32 +402,26 @@ def _ensure_sys_setting_columns() -> None: dialect = engine.dialect.name required_columns = { "name": { - "sqlite": "TEXT NOT NULL DEFAULT ''", "postgresql": "TEXT NOT NULL DEFAULT ''", "mysql": "VARCHAR(200) NOT NULL DEFAULT ''", }, "category": { - "sqlite": "TEXT NOT NULL DEFAULT 'general'", "postgresql": "TEXT NOT NULL DEFAULT 'general'", "mysql": "VARCHAR(64) NOT NULL DEFAULT 'general'", }, "description": { - "sqlite": "TEXT NOT NULL DEFAULT ''", "postgresql": "TEXT NOT NULL DEFAULT ''", "mysql": "LONGTEXT", }, "value_type": { - "sqlite": "TEXT NOT NULL DEFAULT 'json'", "postgresql": "TEXT NOT NULL DEFAULT 'json'", "mysql": "VARCHAR(32) NOT NULL DEFAULT 'json'", }, "is_public": { - "sqlite": "INTEGER NOT NULL DEFAULT 0", "postgresql": "BOOLEAN NOT NULL DEFAULT FALSE", "mysql": "BOOLEAN NOT NULL DEFAULT FALSE", }, "sort_order": { - "sqlite": "INTEGER NOT NULL DEFAULT 100", "postgresql": "INTEGER NOT NULL DEFAULT 100", "mysql": "INTEGER NOT NULL DEFAULT 100", }, @@ -432,7 +438,7 @@ def _ensure_sys_setting_columns() -> None: for col, ddl_map in required_columns.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") + ddl = ddl_map.get(dialect) or ddl_map.get("postgresql") conn.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}")) conn.commit() @@ -441,17 +447,14 @@ def _ensure_bot_request_usage_columns() -> None: dialect = engine.dialect.name required_columns = { "message_id": { - "sqlite": "INTEGER", "postgresql": "INTEGER", "mysql": "INTEGER", }, "provider": { - "sqlite": "TEXT", "postgresql": "TEXT", "mysql": "VARCHAR(120)", }, "model": { - "sqlite": "TEXT", "postgresql": "TEXT", "mysql": "VARCHAR(255)", }, @@ -468,69 +471,105 @@ def _ensure_bot_request_usage_columns() -> None: for col, ddl_map in required_columns.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") + ddl = ddl_map.get(dialect) or ddl_map.get("postgresql") conn.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}")) conn.commit() -def _ensure_topic_tables_sqlite() -> None: - if engine.dialect.name != "sqlite": +def _ensure_managed_node_columns() -> None: + dialect = engine.dialect.name + required_columns = { + "display_name": { + "postgresql": "TEXT NOT NULL DEFAULT ''", + "mysql": "VARCHAR(200) NOT NULL DEFAULT ''", + }, + "base_url": { + "postgresql": "TEXT NOT NULL DEFAULT ''", + "mysql": "TEXT NOT NULL", + }, + "enabled": { + "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", + "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", + }, + "auth_token": { + "postgresql": "TEXT NOT NULL DEFAULT ''", + "mysql": "TEXT NOT NULL", + }, + "transport_kind": { + "postgresql": "TEXT NOT NULL DEFAULT 'direct'", + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'direct'", + }, + "runtime_kind": { + "postgresql": "TEXT NOT NULL DEFAULT 'docker'", + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'docker'", + }, + "core_adapter": { + "postgresql": "TEXT NOT NULL DEFAULT 'nanobot'", + "mysql": "VARCHAR(64) NOT NULL DEFAULT 'nanobot'", + }, + "metadata_json": { + "postgresql": "TEXT NOT NULL DEFAULT '{}'", + "mysql": "LONGTEXT", + }, + "capabilities_json": { + "postgresql": "TEXT NOT NULL DEFAULT '{}'", + "mysql": "LONGTEXT", + }, + "resources_json": { + "postgresql": "TEXT NOT NULL DEFAULT '{}'", + "mysql": "LONGTEXT", + }, + "last_seen_at": { + "postgresql": "TIMESTAMP", + "mysql": "DATETIME", + }, + "created_at": { + "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", + "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + "updated_at": { + "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", + "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + }, + } + inspector = inspect(engine) + if not inspector.has_table(MANAGED_NODE_TABLE): return with engine.connect() as conn: - conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS topic_topic ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bot_id TEXT NOT NULL, - topic_key TEXT NOT NULL, - name TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', - is_active INTEGER NOT NULL DEFAULT 1, - is_default_fallback INTEGER NOT NULL DEFAULT 0, - routing_json TEXT NOT NULL DEFAULT '{}', - view_schema_json TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES bot_instance(id) - ) - """ - ) - ) - conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS topic_item ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bot_id TEXT NOT NULL, - topic_key TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - content TEXT NOT NULL DEFAULT '', - level TEXT NOT NULL DEFAULT 'info', - tags_json TEXT, - view_json TEXT, - source TEXT NOT NULL DEFAULT 'mcp', - dedupe_key TEXT, - is_read INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES bot_instance(id) - ) - """ - ) - ) + existing = { + str(row.get("name")) + for row in inspect(conn).get_columns(MANAGED_NODE_TABLE) + if row.get("name") + } + for col, ddl_map in required_columns.items(): + if col in existing: + continue + ddl = ddl_map.get(dialect) or ddl_map.get("postgresql") + conn.execute(text(f"ALTER TABLE {MANAGED_NODE_TABLE} ADD COLUMN {col} {ddl}")) + conn.commit() - conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key ON topic_topic(bot_id, topic_key)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id ON topic_topic(bot_id)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_fallback ON topic_topic(bot_id, is_default_fallback)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_id ON topic_item(bot_id)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_topic_key ON topic_item(topic_key)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_level ON topic_item(level)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_source ON topic_item(source)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_is_read ON topic_item(is_read)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_created_at ON topic_item(created_at)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_topic_created_at ON topic_item(bot_id, topic_key, created_at)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_dedupe ON topic_item(bot_id, dedupe_key)")) + +def _ensure_managed_node_indexes() -> None: + required_indexes = [ + ("idx_managed_node_enabled", MANAGED_NODE_TABLE, ["enabled"]), + ("idx_managed_node_transport_kind", MANAGED_NODE_TABLE, ["transport_kind"]), + ("idx_managed_node_runtime_kind", MANAGED_NODE_TABLE, ["runtime_kind"]), + ("idx_managed_node_core_adapter", MANAGED_NODE_TABLE, ["core_adapter"]), + ("idx_managed_node_last_seen_at", MANAGED_NODE_TABLE, ["last_seen_at"]), + ] + inspector = inspect(engine) + with engine.connect() as conn: + if not inspector.has_table(MANAGED_NODE_TABLE): + return + existing = { + str(item.get("name")) + for item in inspector.get_indexes(MANAGED_NODE_TABLE) + if item.get("name") + } + for name, table_name, columns in required_indexes: + if name in existing: + continue + conn.execute(text(f"CREATE INDEX {name} ON {table_name} ({', '.join(columns)})")) conn.commit() @@ -539,84 +578,68 @@ def _ensure_topic_columns() -> None: required_columns = { "topic_topic": { "name": { - "sqlite": "TEXT NOT NULL DEFAULT ''", "postgresql": "TEXT NOT NULL DEFAULT ''", "mysql": "VARCHAR(255) NOT NULL DEFAULT ''", }, "description": { - "sqlite": "TEXT NOT NULL DEFAULT ''", "postgresql": "TEXT NOT NULL DEFAULT ''", "mysql": "LONGTEXT", }, "is_active": { - "sqlite": "INTEGER NOT NULL DEFAULT 1", "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", }, "is_default_fallback": { - "sqlite": "INTEGER NOT NULL DEFAULT 0", "postgresql": "BOOLEAN NOT NULL DEFAULT FALSE", "mysql": "BOOLEAN NOT NULL DEFAULT FALSE", }, "routing_json": { - "sqlite": "TEXT NOT NULL DEFAULT '{}'", "postgresql": "TEXT NOT NULL DEFAULT '{}'", "mysql": "LONGTEXT", }, "view_schema_json": { - "sqlite": "TEXT NOT NULL DEFAULT '{}'", "postgresql": "TEXT NOT NULL DEFAULT '{}'", "mysql": "LONGTEXT", }, "created_at": { - "sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", }, "updated_at": { - "sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", }, }, "topic_item": { "title": { - "sqlite": "TEXT NOT NULL DEFAULT ''", "postgresql": "TEXT NOT NULL DEFAULT ''", "mysql": "VARCHAR(2000) NOT NULL DEFAULT ''", }, "level": { - "sqlite": "TEXT NOT NULL DEFAULT 'info'", "postgresql": "TEXT NOT NULL DEFAULT 'info'", "mysql": "VARCHAR(32) NOT NULL DEFAULT 'info'", }, "tags_json": { - "sqlite": "TEXT", "postgresql": "TEXT", "mysql": "LONGTEXT", }, "view_json": { - "sqlite": "TEXT", "postgresql": "TEXT", "mysql": "LONGTEXT", }, "source": { - "sqlite": "TEXT NOT NULL DEFAULT 'mcp'", "postgresql": "TEXT NOT NULL DEFAULT 'mcp'", "mysql": "VARCHAR(64) NOT NULL DEFAULT 'mcp'", }, "dedupe_key": { - "sqlite": "TEXT", "postgresql": "TEXT", "mysql": "VARCHAR(200)", }, "is_read": { - "sqlite": "INTEGER NOT NULL DEFAULT 0", "postgresql": "BOOLEAN NOT NULL DEFAULT FALSE", "mysql": "BOOLEAN NOT NULL DEFAULT FALSE", }, "created_at": { - "sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", }, @@ -636,7 +659,7 @@ def _ensure_topic_columns() -> None: for col, ddl_map in cols.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") + ddl = ddl_map.get(dialect) or ddl_map.get("postgresql") conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}")) conn.commit() @@ -783,10 +806,11 @@ def init_database() -> None: _drop_legacy_skill_tables() _ensure_sys_setting_columns() _ensure_bot_request_usage_columns() + _ensure_managed_node_columns() _ensure_botinstance_columns() + _ensure_botinstance_indexes() + _ensure_managed_node_indexes() _drop_legacy_botinstance_columns() - _ensure_botmessage_columns() - _ensure_topic_tables_sqlite() _repair_postgres_topic_foreign_keys() _ensure_topic_columns() _ensure_topic_indexes() diff --git a/backend/core/settings.py b/backend/core/settings.py index 33c1295..758bf09 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -1,6 +1,7 @@ import json import os import re +import shutil from pathlib import Path from typing import Final from urllib.parse import urlsplit, urlunsplit @@ -119,21 +120,11 @@ BOTS_WORKSPACE_ROOT: Final[str] = _normalize_dir_path( def _normalize_database_url(url: str) -> str: - raw = str(url or "").strip() - prefix = "sqlite:///" - if not raw.startswith(prefix): - return raw - path_part = raw[len(prefix) :] - if not path_part or path_part.startswith("/"): - return raw - abs_path = (BACKEND_ROOT / path_part).resolve() - return f"{prefix}{abs_path.as_posix()}" + return str(url or "").strip() def _database_engine(url: str) -> str: raw = str(url or "").strip().lower() - if raw.startswith("sqlite"): - return "sqlite" if raw.startswith("postgresql"): return "postgresql" if raw.startswith("mysql"): @@ -147,7 +138,7 @@ def _database_engine(url: str) -> str: def _mask_database_url(url: str) -> str: raw = str(url or "").strip() - if not raw or raw.startswith("sqlite"): + if not raw: return raw try: parsed = urlsplit(raw) @@ -168,10 +159,12 @@ def _mask_database_url(url: str) -> str: _db_env = str(os.getenv("DATABASE_URL") or "").strip() -DATABASE_URL: Final[str] = _normalize_database_url( - _db_env if _db_env else f"sqlite:///{Path(DATA_ROOT) / 'nanobot_dashboard.db'}" -) +if not _db_env: + raise RuntimeError("DATABASE_URL is required") +DATABASE_URL: Final[str] = _normalize_database_url(_db_env) DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL) +if DATABASE_ENGINE not in {"postgresql", "mysql"}: + raise RuntimeError(f"Unsupported DATABASE_URL engine: {DATABASE_ENGINE}") DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) DATABASE_POOL_SIZE: Final[int] = _env_int("DATABASE_POOL_SIZE", 20, 1, 200) @@ -223,9 +216,27 @@ REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot") REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400) PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip() -TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve() -AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json" -TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json" +LEGACY_TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve() +TEMPLATE_ROOT: Final[Path] = (Path(DATA_ROOT) / "templates").resolve() +TEMPLATE_ROOT.mkdir(parents=True, exist_ok=True) + + +def _resolve_template_file(filename: str) -> Path: + target = (TEMPLATE_ROOT / filename).resolve() + legacy = (LEGACY_TEMPLATE_ROOT / filename).resolve() + if target.exists(): + return target + if legacy.exists(): + try: + shutil.copy2(legacy, target) + return target + except Exception: + return legacy + return target + + +AGENT_MD_TEMPLATES_FILE: Final[Path] = _resolve_template_file("agent_md_templates.json") +TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = _resolve_template_file("topic_presets.json") _agent_md_templates_raw = _load_json_object(AGENT_MD_TEMPLATES_FILE) DEFAULT_AGENTS_MD: Final[str] = _env_text( diff --git a/backend/data.db b/backend/data.db new file mode 100644 index 0000000..e69de29 diff --git a/backend/main.py b/backend/main.py index 5418003..f9efac6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import mimetypes import os import re import shutil @@ -16,7 +15,7 @@ from zoneinfo import ZoneInfo import httpx from pydantic import BaseModel from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse +from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from sqlmodel import Session, select @@ -59,6 +58,8 @@ from models.skill import BotSkillInstall, SkillMarketItem from models.topic import TopicItem, TopicTopic from api.platform_router import router as platform_router from api.topic_router import router as topic_router +from clients.edge.errors import is_expected_edge_offline_error, log_edge_failure, summarize_edge_exception +from clients.edge.http import HttpEdgeClient from services.topic_runtime import publish_runtime_topic_packet from services.platform_service import ( bind_usage_message, @@ -71,10 +72,42 @@ from services.platform_service import ( prune_expired_activity_events, record_activity_event, ) +from providers.provision.edge import EdgeProvisionProvider +from providers.provision.local import LocalProvisionProvider +from providers.registry import ProviderRegistry +from providers.runtime.edge import EdgeRuntimeProvider +from providers.runtime.local import LocalRuntimeProvider +from providers.selector import get_provision_provider, get_runtime_provider +from providers.target import ( + ProviderTarget, + normalize_provider_target, + provider_target_from_config, + provider_target_to_dict, +) +from providers.workspace.edge import EdgeWorkspaceProvider +from providers.workspace.local import LocalWorkspaceProvider +from services.bot_command_service import BotCommandService +from services.node_registry_service import ManagedNode, NodeRegistryService +from services.runtime_service import RuntimeService +from services.workspace_service import WorkspaceService app = FastAPI(title="Dashboard Nanobot API") logger = logging.getLogger("dashboard.backend") + +def _apply_log_noise_guard() -> None: + for name in ( + "httpx", + "httpcore", + "uvicorn.access", + "watchfiles.main", + "watchfiles.watcher", + ): + logging.getLogger(name).setLevel(logging.WARNING) + + +_apply_log_noise_guard() + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -122,7 +155,7 @@ class BotCreateRequest(BaseModel): llm_provider: str llm_model: str api_key: str - image_tag: str + image_tag: Optional[str] = None system_prompt: Optional[str] = None api_base: Optional[str] = None temperature: float = 0.2 @@ -142,6 +175,10 @@ class BotCreateRequest(BaseModel): channels: Optional[List[ChannelConfigRequest]] = None send_progress: Optional[bool] = None send_tool_hints: Optional[bool] = None + node_id: Optional[str] = None + transport_kind: Optional[str] = None + runtime_kind: Optional[str] = None + core_adapter: Optional[str] = None class BotUpdateRequest(BaseModel): @@ -170,6 +207,17 @@ class BotUpdateRequest(BaseModel): identity_md: Optional[str] = None send_progress: Optional[bool] = None send_tool_hints: Optional[bool] = None + node_id: Optional[str] = None + transport_kind: Optional[str] = None + runtime_kind: Optional[str] = None + core_adapter: Optional[str] = None + + +class BotDeployRequest(BaseModel): + node_id: str + runtime_kind: Optional[str] = None + image_tag: Optional[str] = None + auto_start: bool = False class BotToolsConfigUpdateRequest(BaseModel): @@ -388,6 +436,10 @@ class WSConnectionManager: manager = WSConnectionManager() + +def _broadcast_runtime_packet(bot_id: str, packet: Dict[str, Any], loop: Any) -> None: + asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop) + PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password" @@ -626,22 +678,75 @@ def _invalidate_images_cache() -> None: @app.on_event("startup") async def on_startup(): app.state.main_loop = asyncio.get_running_loop() - print(f"📁 项目根目录: {PROJECT_ROOT}") - print(f"🗄️ 数据库引擎: {DATABASE_ENGINE} (echo={DATABASE_ECHO})") - print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}") - print(f"🧠 Redis 缓存: {'enabled' if cache.ping() else 'disabled'} ({REDIS_URL if REDIS_ENABLED else 'not configured'})") - print(f"🔐 面板访问密码: {'enabled' if str(PANEL_ACCESS_PASSWORD or '').strip() else 'disabled'}") + _provider_target_overrides.clear() + logger.info( + "startup project_root=%s db_engine=%s db_echo=%s db_url=%s redis=%s panel_password=%s", + PROJECT_ROOT, + DATABASE_ENGINE, + DATABASE_ECHO, + DATABASE_URL_DISPLAY, + "enabled" if cache.ping() else ("disabled" if REDIS_ENABLED else "not_configured"), + "enabled" if str(PANEL_ACCESS_PASSWORD or "").strip() else "disabled", + ) init_database() cache.delete_prefix("") with Session(engine) as session: + node_registry_service.load_from_session(session) + node_registry_service.upsert_node(session, _local_managed_node()) pruned_events = prune_expired_activity_events(session, force=True) if pruned_events > 0: session.commit() + target_dirty = False for bot in session.exec(select(BotInstance)).all(): _migrate_bot_resources_store(bot.id) + target = _resolve_bot_provider_target_for_instance(bot) + if str(target.transport_kind or "").strip().lower() != "edge": + target = normalize_provider_target( + { + "node_id": target.node_id, + "transport_kind": "edge", + "runtime_kind": target.runtime_kind, + "core_adapter": target.core_adapter, + }, + fallback=_default_provider_target(), + ) + _set_bot_provider_target(bot.id, target) + if ( + str(getattr(bot, "node_id", "") or "").strip().lower() != target.node_id + or str(getattr(bot, "transport_kind", "") or "").strip().lower() != target.transport_kind + or str(getattr(bot, "runtime_kind", "") or "").strip().lower() != target.runtime_kind + or str(getattr(bot, "core_adapter", "") or "").strip().lower() != target.core_adapter + ): + _apply_provider_target_to_bot(bot, target) + session.add(bot) + target_dirty = True + if target_dirty: + session.commit() running_bots = session.exec(select(BotInstance).where(BotInstance.docker_status == "RUNNING")).all() for bot in running_bots: - docker_manager.ensure_monitor(bot.id, docker_callback) + try: + runtime_service.ensure_monitor(app_state=app.state, bot=bot) + pending_usage = session.exec( + select(BotRequestUsage) + .where(BotRequestUsage.bot_id == str(bot.id or "").strip()) + .where(BotRequestUsage.status == "PENDING") + .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) + .limit(1) + ).first() + if pending_usage and str(getattr(pending_usage, "request_id", "") or "").strip(): + runtime_service.sync_edge_monitor_packets( + app_state=app.state, + bot=bot, + request_id=str(pending_usage.request_id or "").strip(), + ) + except HTTPException as exc: + logger.warning( + "Skip runtime monitor restore on startup for bot_id=%s due to unavailable runtime backend: %s", + str(bot.id or ""), + str(getattr(exc, "detail", "") or exc), + ) + except Exception: + logger.exception("Failed to restore runtime monitor on startup for bot_id=%s", str(bot.id or "")) def _provider_defaults(provider: str) -> tuple[str, str]: @@ -656,6 +761,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]: return "kimi", "https://api.moonshot.cn/v1" if p in {"minimax"}: return "minimax", "https://api.minimax.chat/v1" + if p in {"vllm"}: + return "openai", "" return p, "" @@ -784,6 +891,9 @@ def _config_json_path(bot_id: str) -> str: def _read_bot_config(bot_id: str) -> Dict[str, Any]: + if _resolve_edge_state_context(bot_id) is not None: + data = _read_edge_state_data(bot_id=bot_id, state_key="config", default_payload={}) + return data if isinstance(data, dict) else {} path = _config_json_path(bot_id) if not os.path.isfile(path): return {} @@ -796,14 +906,239 @@ def _read_bot_config(bot_id: str) -> Dict[str, Any]: def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None: + normalized = dict(config_data if isinstance(config_data, dict) else {}) + if _write_edge_state_data(bot_id=bot_id, state_key="config", data=normalized): + return path = _config_json_path(bot_id) os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.tmp" with open(tmp, "w", encoding="utf-8") as f: - json.dump(config_data, f, ensure_ascii=False, indent=2) + json.dump(normalized, f, ensure_ascii=False, indent=2) os.replace(tmp, path) +_provider_target_overrides: Dict[str, ProviderTarget] = {} + + +def _default_provider_target() -> ProviderTarget: + return normalize_provider_target( + { + "node_id": getattr(app.state, "provider_default_node_id", None), + "transport_kind": getattr(app.state, "provider_default_transport_kind", None), + "runtime_kind": getattr(app.state, "provider_default_runtime_kind", None), + "core_adapter": getattr(app.state, "provider_default_core_adapter", None), + }, + fallback=ProviderTarget(), + ) + + +def _read_bot_provider_target(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> ProviderTarget: + normalized_bot_id = str(bot_id or "").strip() + if normalized_bot_id and normalized_bot_id in _provider_target_overrides: + return _provider_target_overrides[normalized_bot_id] + if normalized_bot_id: + with Session(engine) as session: + bot = session.get(BotInstance, normalized_bot_id) + if bot is not None: + return normalize_provider_target( + { + "node_id": getattr(bot, "node_id", None), + "transport_kind": getattr(bot, "transport_kind", None), + "runtime_kind": getattr(bot, "runtime_kind", None), + "core_adapter": getattr(bot, "core_adapter", None), + }, + fallback=_default_provider_target(), + ) + raw_config = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id) + return provider_target_from_config(raw_config, fallback=_default_provider_target()) + + +def _resolve_bot_provider_target_for_instance(bot: BotInstance) -> ProviderTarget: + normalized_bot_id = str(getattr(bot, "id", "") or "").strip() + if normalized_bot_id and normalized_bot_id in _provider_target_overrides: + return _provider_target_overrides[normalized_bot_id] + inline_values = { + "node_id": getattr(bot, "node_id", None), + "transport_kind": getattr(bot, "transport_kind", None), + "runtime_kind": getattr(bot, "runtime_kind", None), + "core_adapter": getattr(bot, "core_adapter", None), + } + if any(str(value or "").strip() for value in inline_values.values()): + return normalize_provider_target(inline_values, fallback=_default_provider_target()) + return _read_bot_provider_target(str(bot.id or "")) + + +def _set_provider_target_override(bot_id: str, target: ProviderTarget) -> None: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return + _provider_target_overrides[normalized_bot_id] = target + + +def _clear_provider_target_override(bot_id: str) -> None: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return + _provider_target_overrides.pop(normalized_bot_id, None) + + +def _apply_provider_target_to_bot(bot: BotInstance, target: ProviderTarget) -> None: + bot.node_id = target.node_id + bot.transport_kind = target.transport_kind + bot.runtime_kind = target.runtime_kind + bot.core_adapter = target.core_adapter + + +def _local_managed_node() -> ManagedNode: + return ManagedNode( + node_id="local", + display_name="Local Node", + base_url=str(os.getenv("LOCAL_EDGE_BASE_URL", "http://127.0.0.1:8010") or "http://127.0.0.1:8010").strip(), + enabled=True, + auth_token=str(os.getenv("EDGE_AUTH_TOKEN", "") or "").strip(), + metadata={ + "transport_kind": "edge", + "runtime_kind": "docker", + "core_adapter": "nanobot", + "workspace_root": str( + os.getenv("EDGE_WORKSPACE_ROOT", os.getenv("EDGE_BOTS_WORKSPACE_ROOT", "")) or "" + ).strip(), + "native_command": str(os.getenv("EDGE_NATIVE_COMMAND", "") or "").strip(), + "native_workdir": str(os.getenv("EDGE_NATIVE_WORKDIR", "") or "").strip(), + "native_sandbox_mode": str(os.getenv("EDGE_NATIVE_SANDBOX_MODE", "inherit") or "inherit").strip().lower(), + }, + ) + + +def _provider_target_from_node(node_id: Optional[str]) -> Optional[ProviderTarget]: + normalized = str(node_id or "").strip().lower() + if not normalized: + return None + node = node_registry_service.get_node(normalized) + if node is None: + return None + metadata = dict(node.metadata or {}) + return ProviderTarget( + node_id=node.node_id, + transport_kind=str(metadata.get("transport_kind") or "edge").strip().lower() or "edge", + runtime_kind=str(metadata.get("runtime_kind") or "docker").strip().lower() or "docker", + core_adapter=str(metadata.get("core_adapter") or "nanobot").strip().lower() or "nanobot", + ) + + +node_registry_service = NodeRegistryService() +node_registry_service.register_node(_local_managed_node()) +app.state.node_registry_service = node_registry_service + + +def _node_display_name(node_id: str) -> str: + node = node_registry_service.get_node(node_id) + if node is not None: + return str(node.display_name or node.node_id or node_id).strip() or str(node_id or "").strip() + return str(node_id or "").strip() + + +def _node_metadata(node_id: str) -> Dict[str, Any]: + node = node_registry_service.get_node(node_id) + if node is None: + return {} + return dict(node.metadata or {}) + + +def _serialize_provider_target_summary(target: ProviderTarget) -> Dict[str, Any]: + return { + **provider_target_to_dict(target), + "node_display_name": _node_display_name(target.node_id), + } + + +def _resolve_edge_client(target: ProviderTarget) -> HttpEdgeClient: + try: + node = node_registry_service.require_node(target.node_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HttpEdgeClient( + node=node, + http_client_factory=lambda: httpx.Client(timeout=15.0, trust_env=False), + async_http_client_factory=lambda: httpx.AsyncClient(timeout=15.0, trust_env=False), + ) + + +def _resolve_edge_state_context(bot_id: str) -> Optional[tuple[HttpEdgeClient, Optional[str], str]]: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return None + with Session(engine) as session: + bot = session.get(BotInstance, normalized_bot_id) + if bot is None: + return None + target = _resolve_bot_provider_target_for_instance(bot) + if str(target.transport_kind or "").strip().lower() != "edge": + return None + client = _resolve_edge_client(target) + metadata = _node_metadata(target.node_id) + workspace_root = str(metadata.get("workspace_root") or "").strip() or None + return client, workspace_root, target.node_id + + +def _read_edge_state_data( + *, + bot_id: str, + state_key: str, + default_payload: Dict[str, Any], +) -> Dict[str, Any]: + context = _resolve_edge_state_context(bot_id) + if context is None: + return dict(default_payload) + client, workspace_root, node_id = context + try: + payload = client.read_state( + bot_id=bot_id, + state_key=state_key, + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + logger, + key=f"edge-state-read:{node_id}:{bot_id}:{state_key}", + exc=exc, + message=f"Failed to read edge state for bot_id={bot_id}, state_key={state_key}", + ) + return dict(default_payload) + data = payload.get("data") + if isinstance(data, dict): + return dict(data) + return dict(default_payload) + + +def _write_edge_state_data( + *, + bot_id: str, + state_key: str, + data: Dict[str, Any], +) -> bool: + context = _resolve_edge_state_context(bot_id) + if context is None: + return False + client, workspace_root, node_id = context + try: + client.write_state( + bot_id=bot_id, + state_key=state_key, + data=dict(data or {}), + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + logger, + key=f"edge-state-write:{node_id}:{bot_id}:{state_key}", + exc=exc, + message=f"Failed to write edge state for bot_id={bot_id}, state_key={state_key}", + ) + raise + return True + + def _resources_json_path(bot_id: str) -> str: return os.path.join(_bot_data_root(bot_id), "resources.json") @@ -815,6 +1150,8 @@ def _write_bot_resources(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb "memoryMB": normalized["memory_mb"], "storageGB": normalized["storage_gb"], } + if _write_edge_state_data(bot_id=bot_id, state_key="resources", data=payload): + return path = _resources_json_path(bot_id) os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.tmp" @@ -823,7 +1160,7 @@ def _write_bot_resources(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb os.replace(tmp, path) -def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _read_legacy_resource_values(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> tuple[Any, Any, Any]: cpu_raw: Any = None memory_raw: Any = None storage_raw: Any = None @@ -853,11 +1190,42 @@ def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = Non memory_raw = resources_raw.get("memoryMB", resources_raw.get("memory_mb")) if storage_raw is None: storage_raw = resources_raw.get("storageGB", resources_raw.get("storage_gb")) + return cpu_raw, memory_raw, storage_raw + +def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + edge_context = _resolve_edge_state_context(bot_id) + cpu_raw: Any = None + memory_raw: Any = None + storage_raw: Any = None + if edge_context is not None: + data = _read_edge_state_data( + bot_id=bot_id, + state_key="resources", + default_payload={}, + ) + cpu_raw = data.get("cpuCores", data.get("cpu_cores")) + memory_raw = data.get("memoryMB", data.get("memory_mb")) + storage_raw = data.get("storageGB", data.get("storage_gb")) + if cpu_raw is None or memory_raw is None or storage_raw is None: + legacy_cpu, legacy_memory, legacy_storage = _read_legacy_resource_values(bot_id, config_data=config_data) + if cpu_raw is None: + cpu_raw = legacy_cpu + if memory_raw is None: + memory_raw = legacy_memory + if storage_raw is None: + storage_raw = legacy_storage + return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw) + + cpu_raw, memory_raw, storage_raw = _read_legacy_resource_values(bot_id, config_data=config_data) return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw) def _migrate_bot_resources_store(bot_id: str) -> None: + edge_context = _resolve_edge_state_context(bot_id) + if edge_context is not None: + return + config_data = _read_bot_config(bot_id) runtime_cfg = config_data.get("runtime") resources_raw: Dict[str, Any] = {} @@ -1334,6 +1702,7 @@ def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str: def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: config_data = _read_bot_config(bot.id) env_params = _resolve_bot_env_params(bot.id) + target = _resolve_bot_provider_target_for_instance(bot) provider_name = "" provider_cfg: Dict[str, Any] = {} @@ -1366,6 +1735,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD) resources = _read_bot_resources(bot.id, config_data=config_data) return { + **provider_target_to_dict(target), "llm_provider": llm_provider, "llm_model": llm_model, "api_key": api_key, @@ -1390,6 +1760,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: runtime = _read_bot_runtime_snapshot(bot) + target = _resolve_bot_provider_target_for_instance(bot) return { "id": bot.id, "name": bot.name, @@ -1412,6 +1783,11 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: "system_timezone": str(runtime.get("system_timezone") or _get_default_system_timezone()), "send_progress": bool(runtime.get("send_progress")), "send_tool_hints": bool(runtime.get("send_tool_hints")), + "node_id": target.node_id, + "node_display_name": _node_display_name(target.node_id), + "transport_kind": target.transport_kind, + "runtime_kind": target.runtime_kind, + "core_adapter": target.core_adapter, "soul_md": runtime.get("soul_md") or "", "agents_md": runtime.get("agents_md") or "", "user_md": runtime.get("user_md") or "", @@ -1427,12 +1803,18 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]: + target = _resolve_bot_provider_target_for_instance(bot) return { "id": bot.id, "name": bot.name, "enabled": bool(getattr(bot, "enabled", True)), "has_access_password": bool(str(bot.access_password or "").strip()), "image_tag": bot.image_tag, + "node_id": target.node_id, + "node_display_name": _node_display_name(target.node_id), + "transport_kind": target.transport_kind, + "runtime_kind": target.runtime_kind, + "core_adapter": target.core_adapter, "docker_status": bot.docker_status, "current_state": bot.current_state, "last_action": bot.last_action, @@ -1440,6 +1822,24 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]: } +def _refresh_bot_runtime_status(app_state: Any, bot: BotInstance) -> str: + current_status = str(bot.docker_status or "STOPPED").upper() + try: + status = str(get_runtime_provider(app_state, bot).get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper() + except Exception as exc: + log_edge_failure( + logger, + key=f"bot-runtime-status:{bot.id}", + exc=exc, + message=f"Failed to refresh runtime status for bot_id={bot.id}", + ) + return current_status + bot.docker_status = status + if status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: + bot.current_state = "IDLE" + return status + + _AGENT_LOOP_READY_MARKER = "Agent loop started" @@ -1511,6 +1911,10 @@ def _sync_workspace_channels( snapshot = _read_bot_runtime_snapshot(bot) bot_data: Dict[str, Any] = { "name": bot.name, + "node_id": snapshot.get("node_id") or _default_provider_target().node_id, + "transport_kind": snapshot.get("transport_kind") or _default_provider_target().transport_kind, + "runtime_kind": snapshot.get("runtime_kind") or _default_provider_target().runtime_kind, + "core_adapter": snapshot.get("core_adapter") or _default_provider_target().core_adapter, "system_prompt": snapshot.get("system_prompt") or DEFAULT_SOUL_MD, "soul_md": snapshot.get("soul_md") or DEFAULT_SOUL_MD, "agents_md": snapshot.get("agents_md") or DEFAULT_AGENTS_MD, @@ -1591,6 +1995,41 @@ def _sync_workspace_channels( ) +def _set_bot_provider_target(bot_id: str, target: ProviderTarget) -> None: + _set_provider_target_override(bot_id, target) + + +def _sync_bot_workspace_via_provider( + session: Session, + bot: BotInstance, + *, + target_override: Optional[ProviderTarget] = None, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, +) -> None: + bot_id = str(bot.id or "") + previous_override = _provider_target_overrides.get(bot_id) + wrote_target = False + try: + if target_override is not None: + _set_bot_provider_target(bot_id, target_override) + wrote_target = True + get_provision_provider(app.state, bot).sync_bot_workspace( + session=session, + bot_id=bot_id, + channels_override=channels_override, + global_delivery_override=global_delivery_override, + runtime_overrides=runtime_overrides, + ) + except Exception: + if wrote_target: + if previous_override is not None: + _set_provider_target_override(bot_id, previous_override) + else: + _clear_provider_target_override(bot_id) + raise + def reconcile_image_registry(session: Session): """Only reconcile status for images explicitly registered in DB.""" db_images = session.exec(select(NanobotImage)).all() @@ -1659,8 +2098,55 @@ def _read_skill_description(entry_path: str) -> str: def _list_workspace_skills(bot_id: str) -> List[Dict[str, Any]]: + edge_context = _resolve_edge_state_context(bot_id) + if edge_context is not None: + client, workspace_root, node_id = edge_context + try: + payload = client.list_tree( + bot_id=bot_id, + path="skills", + recursive=False, + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + logger, + key=f"skills-list:{node_id}:{bot_id}", + exc=exc, + message=f"Failed to list skills from edge workspace for bot_id={bot_id}", + ) + return [] + rows: List[Dict[str, Any]] = [] + for entry in list(payload.get("entries") or []): + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip() + if not name or name.startswith("."): + continue + if not _is_valid_top_level_skill_name(name): + continue + entry_type = str(entry.get("type") or "").strip().lower() + if entry_type not in {"dir", "file"}: + continue + mtime = str(entry.get("mtime") or "").strip() or (datetime.utcnow().isoformat() + "Z") + size = entry.get("size") + rows.append( + { + "id": name, + "name": name, + "type": entry_type, + "path": f"skills/{name}", + "size": int(size) if isinstance(size, (int, float)) and entry_type == "file" else None, + "mtime": mtime, + "description": "", + } + ) + rows.sort(key=lambda row: (row.get("type") != "dir", str(row.get("name") or "").lower())) + return rows + root = _skills_root(bot_id) - os.makedirs(root, exist_ok=True) + if not os.path.isdir(root): + return [] rows: List[Dict[str, Any]] = [] names = sorted(os.listdir(root), key=lambda n: (not os.path.isdir(os.path.join(root, n)), n.lower())) for name in names: @@ -1924,6 +2410,11 @@ def _serialize_skill_market_item( def _install_skill_zip_into_workspace(bot_id: str, zip_path: str) -> Dict[str, Any]: + if _resolve_edge_state_context(bot_id) is not None: + raise HTTPException( + status_code=400, + detail="Edge bot skill install by zip is disabled here. Use edge workspace upload/deploy flow.", + ) try: archive = zipfile.ZipFile(zip_path) except Exception as exc: @@ -2036,6 +2527,9 @@ def _clear_bot_dashboard_direct_session(bot_id: str) -> Dict[str, Any]: def _read_env_store(bot_id: str) -> Dict[str, str]: + if _resolve_edge_state_context(bot_id) is not None: + data = _read_edge_state_data(bot_id=bot_id, state_key="env", default_payload={}) + return _normalize_env_params(data) path = _env_store_path(bot_id) if not os.path.isfile(path): return {} @@ -2048,15 +2542,115 @@ def _read_env_store(bot_id: str) -> Dict[str, str]: def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None: + normalized_env = _normalize_env_params(env_params) + if _write_edge_state_data(bot_id=bot_id, state_key="env", data=normalized_env): + return path = _env_store_path(bot_id) os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.tmp" with open(tmp, "w", encoding="utf-8") as f: - json.dump(_normalize_env_params(env_params), f, ensure_ascii=False, indent=2) + json.dump(normalized_env, f, ensure_ascii=False, indent=2) os.replace(tmp, path) +local_provision_provider = LocalProvisionProvider(sync_workspace_func=_sync_workspace_channels) +local_runtime_provider = LocalRuntimeProvider( + docker_manager=docker_manager, + on_state_change=docker_callback, + provision_provider=local_provision_provider, + read_runtime_snapshot=_read_bot_runtime_snapshot, + resolve_env_params=_resolve_bot_env_params, + write_env_store=_write_env_store, + invalidate_bot_cache=_invalidate_bot_detail_cache, + record_agent_loop_ready_warning=_record_agent_loop_ready_warning, + safe_float=_safe_float, + safe_int=_safe_int, +) +local_workspace_provider = LocalWorkspaceProvider() +edge_provision_provider = EdgeProvisionProvider( + read_provider_target=_read_bot_provider_target, + resolve_edge_client=_resolve_edge_client, + read_runtime_snapshot=_read_bot_runtime_snapshot, + read_bot_channels=_get_bot_channels_from_config, + read_node_metadata=_node_metadata, +) +edge_runtime_provider = EdgeRuntimeProvider( + read_provider_target=_read_bot_provider_target, + resolve_edge_client=_resolve_edge_client, + read_runtime_snapshot=_read_bot_runtime_snapshot, + resolve_env_params=_resolve_bot_env_params, + read_bot_channels=_get_bot_channels_from_config, + read_node_metadata=_node_metadata, +) +edge_workspace_provider = EdgeWorkspaceProvider( + read_provider_target=_read_bot_provider_target, + resolve_edge_client=_resolve_edge_client, + read_node_metadata=_node_metadata, +) +local_provider_target = ProviderTarget( + node_id="local", + transport_kind="edge", + runtime_kind="docker", + core_adapter="nanobot", +) +provider_registry = ProviderRegistry() +provider_registry.register_bundle( + key=local_provider_target.key, + runtime_provider=local_runtime_provider, + workspace_provider=local_workspace_provider, + provision_provider=local_provision_provider, +) +provider_registry.register_bundle( + key=ProviderTarget(node_id="local", transport_kind="edge", runtime_kind="docker", core_adapter="nanobot").key, + runtime_provider=edge_runtime_provider, + workspace_provider=edge_workspace_provider, + provision_provider=edge_provision_provider, +) +provider_registry.register_bundle( + key=ProviderTarget(node_id="local", transport_kind="edge", runtime_kind="native", core_adapter="nanobot").key, + runtime_provider=edge_runtime_provider, + workspace_provider=edge_workspace_provider, + provision_provider=edge_provision_provider, +) +app.state.provider_default_node_id = local_provider_target.node_id +app.state.provider_default_transport_kind = local_provider_target.transport_kind +app.state.provider_default_runtime_kind = local_provider_target.runtime_kind +app.state.provider_default_core_adapter = local_provider_target.core_adapter +app.state.provider_registry = provider_registry +app.state.resolve_bot_provider_target = _resolve_bot_provider_target_for_instance +app.state.resolve_edge_client = _resolve_edge_client +app.state.edge_provision_provider = edge_provision_provider +app.state.edge_runtime_provider = edge_runtime_provider +app.state.edge_workspace_provider = edge_workspace_provider +app.state.provision_provider = local_provision_provider +app.state.runtime_provider = local_runtime_provider +app.state.workspace_provider = local_workspace_provider + + +def _ensure_provider_target_supported(target: ProviderTarget) -> None: + key = provider_registry.resolve_bundle_key(target) + if key is None: + raise HTTPException(status_code=400, detail=f"Execution target is not supported yet: {target.key}") + + def _read_cron_store(bot_id: str) -> Dict[str, Any]: + if _resolve_edge_state_context(bot_id) is not None: + data = _read_edge_state_data( + bot_id=bot_id, + state_key="cron", + default_payload={"version": 1, "jobs": []}, + ) + if not isinstance(data, dict): + return {"version": 1, "jobs": []} + jobs = data.get("jobs") + if not isinstance(jobs, list): + jobs = [] + try: + version = int(data.get("version", 1) or 1) + except Exception: + version = 1 + return {"version": max(1, version), "jobs": jobs} + path = _cron_store_path(bot_id) if not os.path.isfile(path): return {"version": 1, "jobs": []} @@ -2076,11 +2670,21 @@ def _read_cron_store(bot_id: str) -> Dict[str, Any]: def _write_cron_store(bot_id: str, store: Dict[str, Any]) -> None: + normalized_store = dict(store if isinstance(store, dict) else {}) + jobs = normalized_store.get("jobs") + if not isinstance(jobs, list): + normalized_store["jobs"] = [] + try: + normalized_store["version"] = max(1, int(normalized_store.get("version", 1) or 1)) + except Exception: + normalized_store["version"] = 1 + if _write_edge_state_data(bot_id=bot_id, state_key="cron", data=normalized_store): + return path = _cron_store_path(bot_id) os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.tmp" with open(tmp, "w", encoding="utf-8") as f: - json.dump(store, f, ensure_ascii=False, indent=2) + json.dump(normalized_store, f, ensure_ascii=False, indent=2) os.replace(tmp, path) @@ -2130,6 +2734,34 @@ def _is_visual_attachment_path(path: str) -> bool: return _is_image_attachment_path(path) or _is_video_attachment_path(path) +bot_command_service = BotCommandService( + read_runtime_snapshot=_read_bot_runtime_snapshot, + normalize_media_list=_normalize_media_list, + resolve_workspace_path=_resolve_workspace_path, + is_visual_attachment_path=_is_visual_attachment_path, + is_video_attachment_path=_is_video_attachment_path, + create_usage_request=create_usage_request, + record_activity_event=record_activity_event, + fail_latest_usage=fail_latest_usage, + persist_runtime_packet=_persist_runtime_packet, + get_main_loop=lambda app_state: getattr(app_state, "main_loop", None), + broadcast_packet=_broadcast_runtime_packet, +) +workspace_service = WorkspaceService() +runtime_service = RuntimeService( + command_service=bot_command_service, + resolve_runtime_provider=get_runtime_provider, + clear_bot_sessions=_clear_bot_sessions, + clear_dashboard_direct_session_file=_clear_bot_dashboard_direct_session, + invalidate_bot_detail_cache=_invalidate_bot_detail_cache, + invalidate_bot_messages_cache=_invalidate_bot_messages_cache, + record_activity_event=record_activity_event, +) +app.state.bot_command_service = bot_command_service +app.state.workspace_service = workspace_service +app.state.runtime_service = runtime_service + + def _workspace_stat_ctime_iso(stat: os.stat_result) -> str: ts = getattr(stat, "st_birthtime", None) if ts is None: @@ -2382,6 +3014,20 @@ async def test_provider(payload: dict): } +def _require_ready_image(session: Session, image_tag: str, *, require_local_image: bool) -> NanobotImage: + normalized_tag = str(image_tag or "").strip() + if not normalized_tag: + raise HTTPException(status_code=400, detail="image_tag is required") + image_row = session.get(NanobotImage, normalized_tag) + if not image_row: + raise HTTPException(status_code=400, detail=f"Image not registered in DB: {normalized_tag}") + if image_row.status != "READY": + raise HTTPException(status_code=400, detail=f"Image status is not READY: {normalized_tag} ({image_row.status})") + if require_local_image and not docker_manager.has_image(normalized_tag): + raise HTTPException(status_code=400, detail=f"Docker image not found locally: {normalized_tag}") + return image_row + + @app.post("/api/bots") def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)): normalized_bot_id = str(payload.id or "").strip() @@ -2392,26 +3038,39 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session if session.get(BotInstance, normalized_bot_id): raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}") - image_row = session.get(NanobotImage, payload.image_tag) - if not image_row: - raise HTTPException(status_code=400, detail=f"Image not registered in DB: {payload.image_tag}") - if image_row.status != "READY": - raise HTTPException(status_code=400, detail=f"Image status is not READY: {payload.image_tag} ({image_row.status})") - if not docker_manager.has_image(payload.image_tag): - raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}") - normalized_env_params = _normalize_env_params(payload.env_params) try: normalized_env_params["TZ"] = _normalize_system_timezone(payload.system_timezone) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + provider_target = normalize_provider_target( + { + "node_id": payload.node_id, + "transport_kind": payload.transport_kind, + "runtime_kind": payload.runtime_kind, + "core_adapter": payload.core_adapter, + }, + fallback=_provider_target_from_node(payload.node_id) or _default_provider_target(), + ) + _ensure_provider_target_supported(provider_target) + normalized_image_tag = str(payload.image_tag or "").strip() + if provider_target.runtime_kind == "docker": + _require_ready_image( + session, + normalized_image_tag, + require_local_image=True, + ) bot = BotInstance( id=normalized_bot_id, name=payload.name, enabled=bool(payload.enabled) if payload.enabled is not None else True, access_password=str(payload.access_password or ""), - image_tag=payload.image_tag, + image_tag=normalized_image_tag, + node_id=provider_target.node_id, + transport_kind=provider_target.transport_kind, + runtime_kind=provider_target.runtime_kind, + core_adapter=provider_target.core_adapter, workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id), ) @@ -2419,36 +3078,65 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session session.commit() session.refresh(bot) resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb) - _write_env_store(normalized_bot_id, normalized_env_params) - _sync_workspace_channels( - session, - normalized_bot_id, - channels_override=_normalize_initial_channels(normalized_bot_id, payload.channels), - global_delivery_override={ - "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, - "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, - }, - runtime_overrides={ - "llm_provider": payload.llm_provider, - "llm_model": payload.llm_model, - "api_key": payload.api_key, - "api_base": payload.api_base or "", - "temperature": payload.temperature, - "top_p": payload.top_p, - "max_tokens": payload.max_tokens, - "cpu_cores": resource_limits["cpu_cores"], - "memory_mb": resource_limits["memory_mb"], - "storage_gb": resource_limits["storage_gb"], - "system_prompt": payload.system_prompt or payload.soul_md or DEFAULT_SOUL_MD, - "soul_md": payload.soul_md or payload.system_prompt or DEFAULT_SOUL_MD, - "agents_md": payload.agents_md or DEFAULT_AGENTS_MD, - "user_md": payload.user_md or DEFAULT_USER_MD, - "tools_md": payload.tools_md or DEFAULT_TOOLS_MD, - "identity_md": payload.identity_md or DEFAULT_IDENTITY_MD, - "send_progress": bool(payload.send_progress) if payload.send_progress is not None else False, - "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, - }, - ) + workspace_synced = True + sync_error_detail = "" + try: + _write_env_store(normalized_bot_id, normalized_env_params) + _sync_bot_workspace_via_provider( + session, + bot, + target_override=provider_target, + channels_override=_normalize_initial_channels(normalized_bot_id, payload.channels), + global_delivery_override={ + "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, + "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, + }, + runtime_overrides={ + "llm_provider": payload.llm_provider, + "llm_model": payload.llm_model, + "api_key": payload.api_key, + "api_base": payload.api_base or "", + "temperature": payload.temperature, + "top_p": payload.top_p, + "max_tokens": payload.max_tokens, + "cpu_cores": resource_limits["cpu_cores"], + "memory_mb": resource_limits["memory_mb"], + "storage_gb": resource_limits["storage_gb"], + "node_id": provider_target.node_id, + "transport_kind": provider_target.transport_kind, + "runtime_kind": provider_target.runtime_kind, + "core_adapter": provider_target.core_adapter, + "system_prompt": payload.system_prompt or payload.soul_md or DEFAULT_SOUL_MD, + "soul_md": payload.soul_md or payload.system_prompt or DEFAULT_SOUL_MD, + "agents_md": payload.agents_md or DEFAULT_AGENTS_MD, + "user_md": payload.user_md or DEFAULT_USER_MD, + "tools_md": payload.tools_md or DEFAULT_TOOLS_MD, + "identity_md": payload.identity_md or DEFAULT_IDENTITY_MD, + "send_progress": bool(payload.send_progress) if payload.send_progress is not None else False, + "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, + }, + ) + except Exception as exc: + if is_expected_edge_offline_error(exc): + workspace_synced = False + sync_error_detail = summarize_edge_exception(exc) + logger.info( + "Create bot pending sync due to offline edge bot_id=%s node=%s detail=%s", + normalized_bot_id, + provider_target.node_id, + sync_error_detail, + ) + else: + detail = summarize_edge_exception(exc) + try: + doomed = session.get(BotInstance, normalized_bot_id) + if doomed is not None: + session.delete(doomed) + session.commit() + _clear_provider_target_override(normalized_bot_id) + except Exception: + session.rollback() + raise HTTPException(status_code=502, detail=f"Failed to initialize bot workspace: {detail}") from exc session.refresh(bot) record_activity_event( session, @@ -2456,26 +3144,38 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session "bot_created", channel="system", detail=f"Bot {normalized_bot_id} created", - metadata={"image_tag": payload.image_tag}, + metadata={ + "image_tag": normalized_image_tag, + "workspace_synced": workspace_synced, + "sync_error": sync_error_detail if not workspace_synced else "", + }, ) + if not workspace_synced: + record_activity_event( + session, + normalized_bot_id, + "bot_warning", + channel="system", + detail="Bot created, but node is offline. Workspace sync is pending.", + metadata={"sync_error": sync_error_detail, "node_id": provider_target.node_id}, + ) session.commit() _invalidate_bot_detail_cache(normalized_bot_id) return _serialize_bot(bot) @app.get("/api/bots") -def list_bots(session: Session = Depends(get_session)): +def list_bots(request: Request, session: Session = Depends(get_session)): cached = cache.get_json(_cache_key_bots_list()) if isinstance(cached, list): return cached bots = session.exec(select(BotInstance)).all() dirty = False for bot in bots: - actual_status = docker_manager.get_bot_status(bot.id) - if bot.docker_status != actual_status: - bot.docker_status = actual_status - if actual_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: - bot.current_state = "IDLE" + previous_status = str(bot.docker_status or "").upper() + previous_state = str(bot.current_state or "") + actual_status = _refresh_bot_runtime_status(request.app.state, bot) + if previous_status != actual_status or previous_state != str(bot.current_state or ""): session.add(bot) dirty = True if dirty: @@ -2488,13 +3188,20 @@ def list_bots(session: Session = Depends(get_session)): @app.get("/api/bots/{bot_id}") -def get_bot_detail(bot_id: str, session: Session = Depends(get_session)): +def get_bot_detail(bot_id: str, request: Request, session: Session = Depends(get_session)): cached = cache.get_json(_cache_key_bot_detail(bot_id)) if isinstance(cached, dict): return cached bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") + previous_status = str(bot.docker_status or "").upper() + previous_state = str(bot.current_state or "") + actual_status = _refresh_bot_runtime_status(request.app.state, bot) + if previous_status != actual_status or previous_state != str(bot.current_state or ""): + session.add(bot) + session.commit() + session.refresh(bot) row = _serialize_bot(bot) cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30) return row @@ -2519,15 +3226,42 @@ def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Sessi @app.get("/api/bots/{bot_id}/resources") -def get_bot_resources(bot_id: str, session: Session = Depends(get_session)): +def get_bot_resources(bot_id: str, request: Request, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") configured = _read_bot_resources(bot_id) - runtime = docker_manager.get_bot_resource_snapshot(bot_id) - workspace_root = _workspace_root(bot_id) - workspace_bytes = _calc_dir_size_bytes(workspace_root) + try: + runtime = get_runtime_provider(request.app.state, bot).get_resource_snapshot(bot_id=bot_id) + except Exception as exc: + log_edge_failure( + logger, + key=f"bot-resources:{bot_id}", + exc=exc, + message=f"Failed to refresh bot resources for bot_id={bot_id}", + ) + runtime = {"usage": {}, "limits": {}, "docker_status": str(bot.docker_status or "STOPPED").upper()} + runtime_status = str(runtime.get("docker_status") or "").upper() + previous_status = str(bot.docker_status or "").upper() + previous_state = str(bot.current_state or "") + if runtime_status: + bot.docker_status = runtime_status + if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: + bot.current_state = "IDLE" + if previous_status != str(bot.docker_status or "").upper() or previous_state != str(bot.current_state or ""): + session.add(bot) + session.commit() + session.refresh(bot) + target = _resolve_bot_provider_target_for_instance(bot) + usage_payload = dict(runtime.get("usage") or {}) + workspace_bytes = int(usage_payload.get("container_rw_bytes") or usage_payload.get("workspace_used_bytes") or 0) + workspace_root = "" + if workspace_bytes <= 0: + workspace_root = _workspace_root(bot_id) + workspace_bytes = _calc_dir_size_bytes(workspace_root) + elif target.transport_kind != "edge": + workspace_root = _workspace_root(bot_id) configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024 workspace_percent = 0.0 if configured_storage_bytes > 0: @@ -2544,7 +3278,7 @@ def get_bot_resources(bot_id: str, session: Session = Depends(get_session)): "configured": configured, "runtime": runtime, "workspace": { - "path": workspace_root, + "path": workspace_root or None, "usage_bytes": workspace_bytes, "configured_limit_bytes": configured_storage_bytes if configured_storage_bytes > 0 else None, "usage_percent": max(0.0, workspace_percent), @@ -2570,16 +3304,6 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend update_data = payload.model_dump(exclude_unset=True) - if "image_tag" in update_data and update_data["image_tag"]: - image_tag = str(update_data["image_tag"]).strip() - image_row = session.get(NanobotImage, image_tag) - if not image_row: - raise HTTPException(status_code=400, detail=f"Image not registered in DB: {image_tag}") - if image_row.status != "READY": - raise HTTPException(status_code=400, detail=f"Image status is not READY: {image_tag} ({image_row.status})") - if not docker_manager.has_image(image_tag): - raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}") - env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None normalized_system_timezone: Optional[str] = None @@ -2611,9 +3335,22 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend "send_tool_hints", "system_prompt", } + execution_target_fields = { + "node_id", + "transport_kind", + "runtime_kind", + "core_adapter", + } + deploy_only_fields = {"image_tag", *execution_target_fields} + if deploy_only_fields & set(update_data.keys()): + raise HTTPException( + status_code=400, + detail=f"Use /api/bots/{bot_id}/deploy for execution target or image changes", + ) for field in runtime_fields: if field in update_data: runtime_overrides[field] = update_data.pop(field) + next_target: Optional[ProviderTarget] = None # Never allow empty placeholders to overwrite existing runtime model settings. for text_field in ("llm_provider", "llm_model", "api_key"): @@ -2638,21 +3375,20 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend ) runtime_overrides.update(normalized_resources) - db_fields = {"name", "image_tag", "access_password", "enabled"} + db_fields = {"name", "access_password", "enabled"} for key, value in update_data.items(): if key in db_fields: setattr(bot, key, value) - session.add(bot) - session.commit() - session.refresh(bot) + previous_env_params: Optional[Dict[str, str]] = None + next_env_params: Optional[Dict[str, str]] = None if env_params is not None or normalized_system_timezone is not None: - next_env_params = _resolve_bot_env_params(bot_id) + previous_env_params = _resolve_bot_env_params(bot_id) + next_env_params = dict(previous_env_params) if env_params is not None: next_env_params = _normalize_env_params(env_params) if normalized_system_timezone is not None: next_env_params["TZ"] = normalized_system_timezone - _write_env_store(bot_id, next_env_params) global_delivery_override: Optional[Dict[str, Any]] = None if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides: global_delivery_override = {} @@ -2661,76 +3397,136 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend if "send_tool_hints" in runtime_overrides: global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints")) - _sync_workspace_channels( + _sync_bot_workspace_via_provider( session, - bot_id, + bot, + target_override=next_target, runtime_overrides=runtime_overrides if runtime_overrides else None, global_delivery_override=global_delivery_override, ) + try: + if next_env_params is not None: + _write_env_store(bot_id, next_env_params) + if next_target is not None: + _apply_provider_target_to_bot(bot, next_target) + session.add(bot) + session.commit() + except Exception: + session.rollback() + if previous_env_params is not None: + _write_env_store(bot_id, previous_env_params) + raise session.refresh(bot) _invalidate_bot_detail_cache(bot_id) return _serialize_bot(bot) -@app.post("/api/bots/{bot_id}/start") -async def start_bot(bot_id: str, session: Session = Depends(get_session)): +@app.post("/api/bots/{bot_id}/deploy") +async def deploy_bot(bot_id: str, payload: BotDeployRequest, request: Request, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - if not bool(getattr(bot, "enabled", True)): - raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") - _sync_workspace_channels(session, bot_id) - runtime_snapshot = _read_bot_runtime_snapshot(bot) - env_params = _resolve_bot_env_params(bot_id) - _write_env_store(bot_id, env_params) - success = docker_manager.start_bot( - bot_id, - image_tag=bot.image_tag, - on_state_change=docker_callback, - env_vars=env_params, - cpu_cores=_safe_float(runtime_snapshot.get("cpu_cores"), 1.0), - memory_mb=_safe_int(runtime_snapshot.get("memory_mb"), 1024), - storage_gb=_safe_int(runtime_snapshot.get("storage_gb"), 10), - ) - if not success: - bot.docker_status = "STOPPED" - session.add(bot) - session.commit() - raise HTTPException(status_code=500, detail=f"Failed to start container with image {bot.image_tag}") - actual_status = docker_manager.get_bot_status(bot_id) - bot.docker_status = actual_status - if actual_status != "RUNNING": - session.add(bot) - session.commit() - _invalidate_bot_detail_cache(bot_id) - raise HTTPException( - status_code=500, - detail="Bot container failed shortly after startup. Check bot logs/config.", - ) - asyncio.create_task(_record_agent_loop_ready_warning(bot_id)) + actual_status = _refresh_bot_runtime_status(request.app.state, bot) session.add(bot) - record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}") session.commit() + if actual_status == "RUNNING": + raise HTTPException(status_code=409, detail="Stop the bot before deploy or migrate") + + current_target = _resolve_bot_provider_target_for_instance(bot) + next_target_base = _provider_target_from_node(payload.node_id) + if next_target_base is None: + raise HTTPException(status_code=400, detail=f"Managed node not found: {payload.node_id}") + next_target = normalize_provider_target( + { + "node_id": payload.node_id, + "runtime_kind": payload.runtime_kind, + }, + fallback=next_target_base, + ) + _ensure_provider_target_supported(next_target) + + existing_image_tag = str(bot.image_tag or "").strip() + requested_image_tag = str(payload.image_tag or "").strip() + if next_target.runtime_kind == "docker": + requested_image_tag = requested_image_tag or existing_image_tag + image_changed = requested_image_tag != str(bot.image_tag or "").strip() + target_changed = next_target.key != current_target.key + if not image_changed and not target_changed: + raise HTTPException(status_code=400, detail="No deploy changes detected") + + if next_target.runtime_kind == "docker": + _require_ready_image( + session, + requested_image_tag, + require_local_image=True, + ) + + _sync_bot_workspace_via_provider( + session, + bot, + target_override=next_target, + runtime_overrides=provider_target_to_dict(next_target), + ) + + previous_image_tag = str(bot.image_tag or "").strip() + bot.image_tag = requested_image_tag + _apply_provider_target_to_bot(bot, next_target) + bot.updated_at = datetime.utcnow() + session.add(bot) + record_activity_event( + session, + bot_id, + "bot_deployed", + channel="system", + detail=( + f"Bot {bot_id} deployed to {_node_display_name(next_target.node_id)}" + if target_changed + else f"Bot {bot_id} redeployed with image {requested_image_tag}" + ), + metadata={ + "previous_target": _serialize_provider_target_summary(current_target), + "next_target": _serialize_provider_target_summary(next_target), + "previous_image_tag": previous_image_tag, + "image_tag": requested_image_tag, + "auto_start": bool(payload.auto_start), + }, + ) + session.commit() + session.refresh(bot) + + started = False + if bool(payload.auto_start): + await runtime_service.start_bot(app_state=request.app.state, session=session, bot=bot) + session.refresh(bot) + started = True + _invalidate_bot_detail_cache(bot_id) - return {"status": "started"} + return { + "status": "deployed", + "bot": _serialize_bot(bot), + "started": started, + "image_tag": requested_image_tag, + "previous_image_tag": previous_image_tag, + "previous_target": _serialize_provider_target_summary(current_target), + "next_target": _serialize_provider_target_summary(next_target), + } + + +@app.post("/api/bots/{bot_id}/start") +async def start_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + return await runtime_service.start_bot(app_state=request.app.state, session=session, bot=bot) @app.post("/api/bots/{bot_id}/stop") -def stop_bot(bot_id: str, session: Session = Depends(get_session)): +def stop_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - if not bool(getattr(bot, "enabled", True)): - raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") - - docker_manager.stop_bot(bot_id) - bot.docker_status = "STOPPED" - session.add(bot) - record_activity_event(session, bot_id, "bot_stopped", channel="system", detail=f"Container stopped for {bot_id}") - session.commit() - _invalidate_bot_detail_cache(bot_id) - return {"status": "stopped"} + return runtime_service.stop_bot(app_state=request.app.state, session=session, bot=bot) @app.post("/api/bots/{bot_id}/enable") @@ -2748,12 +3544,15 @@ def enable_bot(bot_id: str, session: Session = Depends(get_session)): @app.post("/api/bots/{bot_id}/disable") -def disable_bot(bot_id: str, session: Session = Depends(get_session)): +def disable_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - docker_manager.stop_bot(bot_id) + try: + runtime_service.stop_bot(app_state=request.app.state, session=session, bot=bot) + except Exception: + pass bot.enabled = False bot.docker_status = "STOPPED" if str(bot.current_state or "").upper() not in {"ERROR"}: @@ -2766,12 +3565,15 @@ def disable_bot(bot_id: str, session: Session = Depends(get_session)): @app.post("/api/bots/{bot_id}/deactivate") -def deactivate_bot(bot_id: str, session: Session = Depends(get_session)): +def deactivate_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - docker_manager.stop_bot(bot_id) + try: + runtime_service.stop_bot(app_state=request.app.state, session=session, bot=bot) + except Exception: + pass bot.enabled = False bot.docker_status = "STOPPED" if str(bot.current_state or "").upper() not in {"ERROR"}: @@ -2784,12 +3586,39 @@ def deactivate_bot(bot_id: str, session: Session = Depends(get_session)): @app.delete("/api/bots/{bot_id}") -def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = Depends(get_session)): +def delete_bot(bot_id: str, request: Request, delete_workspace: bool = True, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - docker_manager.stop_bot(bot_id) + target = _resolve_bot_provider_target_for_instance(bot) + try: + runtime_service.stop_bot(app_state=request.app.state, session=session, bot=bot) + except Exception: + pass + + workspace_deleted = not bool(delete_workspace) + if delete_workspace: + if target.transport_kind == "edge": + try: + workspace_root = str(_node_metadata(target.node_id).get("workspace_root") or "").strip() or None + purge_result = _resolve_edge_client(target).purge_workspace( + bot_id=bot_id, + workspace_root=workspace_root, + ) + workspace_deleted = str(purge_result.get("status") or "").strip().lower() in {"deleted", "not_found"} + except Exception as exc: + log_edge_failure( + logger, + key=f"bot-delete-workspace:{bot_id}", + exc=exc, + message=f"Failed to purge edge workspace for bot_id={bot_id}", + ) + workspace_deleted = False + workspace_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id) + if os.path.isdir(workspace_root): + shutil.rmtree(workspace_root, ignore_errors=True) + workspace_deleted = True messages = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all() for row in messages: @@ -2812,15 +3641,11 @@ def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = De session.delete(bot) session.commit() - - if delete_workspace: - workspace_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id) - if os.path.isdir(workspace_root): - shutil.rmtree(workspace_root, ignore_errors=True) + _clear_provider_target_override(bot_id) _invalidate_bot_detail_cache(bot_id) _invalidate_bot_messages_cache(bot_id) - return {"status": "deleted", "workspace_deleted": bool(delete_workspace)} + return {"status": "deleted", "workspace_deleted": workspace_deleted} @app.get("/api/bots/{bot_id}/channels") @@ -2969,6 +3794,7 @@ def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)): if skill_id <= 0 or row.status != "INSTALLED": continue install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1 + workspace_skill_names = {str(row.get("name") or "").strip() for row in _list_workspace_skills(bot_id)} return [ _serialize_skill_market_item( item, @@ -2980,7 +3806,7 @@ def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)): else ( install_lookup[int(item.id or 0)].status == "INSTALLED" and all( - os.path.exists(os.path.join(_skills_root(bot_id), name)) + name in workspace_skill_names for name in _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json) ) ) @@ -3218,6 +4044,11 @@ def delete_bot_skill(bot_id: str, skill_name: str, session: Session = Depends(ge bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") + if _resolve_edge_state_context(bot_id) is not None: + raise HTTPException( + status_code=400, + detail="Edge bot skill delete is disabled here. Use edge workspace file management.", + ) name = str(skill_name or "").strip() if not _is_valid_top_level_skill_name(name): raise HTTPException(status_code=400, detail="Invalid skill name") @@ -3268,7 +4099,7 @@ def create_bot_channel(bot_id: str, payload: ChannelConfigRequest, session: Sess config_data["channels"] = channels_cfg channels_cfg[ctype] = _channel_api_to_cfg(new_row) _write_bot_config(bot_id, config_data) - _sync_workspace_channels(session, bot_id) + _sync_bot_workspace_via_provider(session, bot) _invalidate_bot_detail_cache(bot_id) return new_row @@ -3341,7 +4172,7 @@ def update_bot_channel( channels_cfg[new_type] = _channel_api_to_cfg(row) _write_bot_config(bot_id, config_data) session.commit() - _sync_workspace_channels(session, bot_id) + _sync_bot_workspace_via_provider(session, bot) _invalidate_bot_detail_cache(bot_id) return row @@ -3368,158 +4199,28 @@ def delete_bot_channel(bot_id: str, channel_id: str, session: Session = Depends( channels_cfg.pop(str(row.get("channel_type") or "").lower(), None) _write_bot_config(bot_id, config_data) session.commit() - _sync_workspace_channels(session, bot_id) + _sync_bot_workspace_via_provider(session, bot) _invalidate_bot_detail_cache(bot_id) return {"status": "deleted"} @app.post("/api/bots/{bot_id}/command") -def send_command(bot_id: str, payload: CommandRequest, session: Session = Depends(get_session)): +def send_command( + bot_id: str, + payload: CommandRequest, + request: Request, + session: Session = Depends(get_session), +): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - runtime_snapshot = _read_bot_runtime_snapshot(bot) - - attachments = _normalize_media_list(payload.attachments, bot_id) - command = str(payload.command or "").strip() - if not command and not attachments: - raise HTTPException(status_code=400, detail="Command or attachments is required") - - checked_attachments: List[str] = [] - for rel in attachments: - _, target = _resolve_workspace_path(bot_id, rel) - if not os.path.isfile(target): - raise HTTPException(status_code=400, detail=f"attachment not found: {rel}") - checked_attachments.append(rel) - delivery_media = [f"/root/.nanobot/workspace/{p.lstrip('/')}" for p in checked_attachments] - - display_command = command if command else "[attachment message]" - delivery_command = display_command - if checked_attachments: - attachment_block = "\n".join(f"- {p}" for p in checked_attachments) - all_visual = all(_is_visual_attachment_path(p) for p in checked_attachments) - if all_visual: - has_video = any(_is_video_attachment_path(p) for p in checked_attachments) - media_label = "图片/视频" if has_video else "图片" - capability_hint = ( - "1) 附件已随请求附带;图片在可用时可直接作为多模态输入理解,视频请按附件路径处理。\n" - if has_video - else "1) 附件中的图片已作为多模态输入提供,优先直接理解并回答。\n" - ) - if command: - delivery_command = ( - f"{command}\n\n" - "[Attached files]\n" - f"{attachment_block}\n\n" - "【附件处理要求】\n" - f"{capability_hint}" - "2) 若当前模型或接口不支持直接理解该附件,请明确说明后再调用工具解析。\n" - "3) 除非用户明确要求,不要先调用工具读取附件文件。\n" - "4) 回复语言必须遵循 USER.md;若未指定,则与用户当前输入语言保持一致。\n" - "5) 仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。" - ) - else: - delivery_command = ( - "请先处理已附带的附件列表:\n" - f"{attachment_block}\n\n" - f"请直接分析已附带的{media_label}并总结关键信息。\n" - f"{'图片在可用时可直接作为多模态输入理解,视频请按附件路径处理。' if has_video else ''}\n" - "若当前模型或接口不支持直接理解该附件,请明确说明后再调用工具解析。\n" - "回复语言必须遵循 USER.md;若未指定,则与用户当前输入语言保持一致。\n" - "仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。" - ) - else: - command_has_paths = all(p in command for p in checked_attachments) if command else False - if command and not command_has_paths: - delivery_command = ( - f"{command}\n\n" - "[Attached files]\n" - f"{attachment_block}\n\n" - "Please process the attached file(s) listed above when answering this request.\n" - "Reply language must follow USER.md. If not specified, use the same language as the user input." - ) - elif not command: - delivery_command = ( - "Please process the uploaded file(s) listed below:\n" - f"{attachment_block}\n\n" - "Reply language must follow USER.md. If not specified, use the same language as the user input." - ) - - request_id = create_usage_request( - session, - bot_id, - display_command, - attachments=checked_attachments, - channel="dashboard", - metadata={"attachment_count": len(checked_attachments)}, - provider=str(runtime_snapshot.get("llm_provider") or "").strip() or None, - model=str(runtime_snapshot.get("llm_model") or "").strip() or None, + return runtime_service.send_command( + app_state=request.app.state, + session=session, + bot_id=bot_id, + bot=bot, + payload=payload, ) - record_activity_event( - session, - bot_id, - "command_submitted", - request_id=request_id, - channel="dashboard", - detail="command submitted", - metadata={ - "attachment_count": len(checked_attachments), - "has_text": bool(command), - }, - ) - session.commit() - - outbound_user_packet: Optional[Dict[str, Any]] = None - if display_command or checked_attachments: - outbound_user_packet = { - "type": "USER_COMMAND", - "channel": "dashboard", - "text": display_command, - "media": checked_attachments, - "request_id": request_id, - } - _persist_runtime_packet(bot_id, outbound_user_packet) - - loop = getattr(app.state, "main_loop", None) - if loop and loop.is_running() and outbound_user_packet: - asyncio.run_coroutine_threadsafe( - manager.broadcast(bot_id, outbound_user_packet), - loop, - ) - - success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media) - if not success: - detail = docker_manager.get_last_delivery_error(bot_id) - fail_latest_usage(session, bot_id, detail or "command delivery failed") - record_activity_event( - session, - bot_id, - "command_failed", - request_id=request_id, - channel="dashboard", - detail=(detail or "command delivery failed")[:400], - ) - session.commit() - if loop and loop.is_running(): - asyncio.run_coroutine_threadsafe( - manager.broadcast( - bot_id, - { - "type": "AGENT_STATE", - "channel": "dashboard", - "payload": { - "state": "ERROR", - "action_msg": detail or "command delivery failed", - }, - }, - ), - loop, - ) - raise HTTPException( - status_code=502, - detail=f"Failed to deliver command to bot dashboard channel{': ' + detail if detail else ''}", - ) - return {"success": True} @app.get("/api/bots/{bot_id}/messages") @@ -3739,35 +4440,7 @@ def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all() - deleted = 0 - for row in rows: - session.delete(row) - deleted += 1 - cleared_sessions = _clear_bot_sessions(bot_id) - # Also reset in-memory session cache for running bot process. - if str(bot.docker_status or "").upper() == "RUNNING": - try: - docker_manager.send_command(bot_id, "/new") - except Exception: - pass - bot.last_action = "" - bot.current_state = "IDLE" - bot.updated_at = datetime.utcnow() - session.add(bot) - record_activity_event( - session, - bot_id, - "history_cleared", - channel="system", - detail=f"Cleared {deleted} stored messages", - metadata={"deleted_messages": deleted, "cleared_sessions": cleared_sessions}, - ) - session.commit() - _invalidate_bot_detail_cache(bot_id) - _invalidate_bot_messages_cache(bot_id) - return {"bot_id": bot_id, "deleted": deleted, "cleared_sessions": cleared_sessions} + return runtime_service.clear_messages(app_state=app.state, session=session, bot=bot) @app.post("/api/bots/{bot_id}/sessions/dashboard-direct/clear") @@ -3775,35 +4448,15 @@ def clear_bot_dashboard_direct_session(bot_id: str, session: Session = Depends(g bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - result = _clear_bot_dashboard_direct_session(bot_id) - if str(bot.docker_status or "").upper() == "RUNNING": - try: - docker_manager.send_command(bot_id, "/new") - except Exception: - pass - - bot.updated_at = datetime.utcnow() - session.add(bot) - record_activity_event( - session, - bot_id, - "dashboard_session_cleared", - channel="dashboard", - detail="Cleared dashboard_direct session file", - metadata={"session_file": result["path"], "previously_existed": result["existed"]}, - ) - session.commit() - _invalidate_bot_detail_cache(bot_id) - return {"bot_id": bot_id, "cleared": True, "session_file": result["path"], "previously_existed": result["existed"]} + return runtime_service.clear_dashboard_direct_session(app_state=app.state, session=session, bot=bot) @app.get("/api/bots/{bot_id}/logs") -def get_bot_logs(bot_id: str, tail: int = 300, session: Session = Depends(get_session)): +def get_bot_logs(bot_id: str, tail: int = 300, request: Request = None, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - return {"bot_id": bot_id, "logs": docker_manager.get_recent_logs(bot_id, tail=tail)} + return runtime_service.get_logs(app_state=request.app.state, bot=bot, tail=tail) @app.get("/api/bots/{bot_id}/workspace/tree") @@ -3811,35 +4464,13 @@ def get_workspace_tree( bot_id: str, path: Optional[str] = None, recursive: bool = False, + request: Request = None, session: Session = Depends(get_session), ): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - root = _workspace_root(bot_id) - if not os.path.isdir(root): - return {"bot_id": bot_id, "root": root, "cwd": "", "parent": None, "entries": []} - - _, target = _resolve_workspace_path(bot_id, path) - if not os.path.isdir(target): - raise HTTPException(status_code=400, detail="workspace path is not a directory") - cwd = os.path.relpath(target, root).replace("\\", "/") - if cwd == ".": - cwd = "" - parent = None - if cwd: - parent = os.path.dirname(cwd).replace("\\", "/") - if parent == ".": - parent = "" - - return { - "bot_id": bot_id, - "root": root, - "cwd": cwd, - "parent": parent, - "entries": _list_workspace_dir_recursive(target, root) if recursive else _list_workspace_dir(target, root), - } + return workspace_service.list_tree(app_state=request.app.state, bot=bot, path=path, recursive=recursive) @app.get("/api/bots/{bot_id}/workspace/file") @@ -3847,57 +4478,13 @@ def read_workspace_file( bot_id: str, path: str, max_bytes: int = 200000, + request: Request = None, session: Session = Depends(get_session), ): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - root, target = _resolve_workspace_path(bot_id, path) - if not os.path.isfile(target): - raise HTTPException(status_code=404, detail="workspace file not found") - - ext = os.path.splitext(target)[1].lower() - text_ext = { - "", - ".md", - ".txt", - ".log", - ".json", - ".yaml", - ".yml", - ".cfg", - ".ini", - ".csv", - ".tsv", - ".toml", - ".py", - ".sh", - } - if ext not in text_ext: - raise HTTPException(status_code=400, detail=f"unsupported file type: {ext or '(none)'}") - - safe_max = max(4096, min(int(max_bytes), 1000000)) - with open(target, "rb") as f: - raw = f.read(safe_max + 1) - - if b"\x00" in raw: - raise HTTPException(status_code=400, detail="binary file is not previewable") - - truncated = len(raw) > safe_max - body = raw[:safe_max] if truncated else raw - text_body = body.decode("utf-8", errors="replace") - rel_path = os.path.relpath(target, root).replace("\\", "/") - is_markdown = ext in {".md", ".markdown"} - - return { - "bot_id": bot_id, - "path": rel_path, - "size": os.path.getsize(target), - "is_markdown": is_markdown, - "truncated": truncated, - "content": text_body, - } + return workspace_service.read_file(app_state=request.app.state, bot=bot, path=path, max_bytes=max_bytes) @app.put("/api/bots/{bot_id}/workspace/file") @@ -3905,99 +4492,19 @@ def update_workspace_file( bot_id: str, path: str, payload: WorkspaceFileUpdateRequest, + request: Request = None, session: Session = Depends(get_session), ): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - root, target = _resolve_workspace_path(bot_id, path) - if not os.path.isfile(target): - raise HTTPException(status_code=404, detail="workspace file not found") - - ext = os.path.splitext(target)[1].lower() - if ext not in {".md", ".markdown"}: - raise HTTPException(status_code=400, detail=f"editing is only supported for markdown files: {ext or '(none)'}") - - content = str(payload.content or "") - encoded = content.encode("utf-8") - if len(encoded) > 2_000_000: - raise HTTPException(status_code=413, detail="markdown file too large to save") - if "\x00" in content: - raise HTTPException(status_code=400, detail="markdown content contains invalid null bytes") - - _write_text_atomic(target, content) - rel_path = os.path.relpath(target, root).replace("\\", "/") - return { - "bot_id": bot_id, - "path": rel_path, - "size": os.path.getsize(target), - "is_markdown": True, - "truncated": False, - "content": content, - } - - -def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024): - with open(target, "rb") as fh: - fh.seek(start) - remaining = end - start + 1 - while remaining > 0: - chunk = fh.read(min(chunk_size, remaining)) - if not chunk: - break - remaining -= len(chunk) - yield chunk - - -def _build_ranged_workspace_response(target: str, media_type: str, range_header: str): - file_size = os.path.getsize(target) - range_match = re.match(r"bytes=(\d*)-(\d*)", range_header.strip()) - if not range_match: - raise HTTPException(status_code=416, detail="Invalid range") - - start_raw, end_raw = range_match.groups() - if start_raw == "" and end_raw == "": - raise HTTPException(status_code=416, detail="Invalid range") - - if start_raw == "": - length = int(end_raw) - if length <= 0: - raise HTTPException(status_code=416, detail="Invalid range") - start = max(file_size - length, 0) - end = file_size - 1 - else: - start = int(start_raw) - end = int(end_raw) if end_raw else file_size - 1 - - if start >= file_size or start < 0: - raise HTTPException(status_code=416, detail="Requested range not satisfiable") - end = min(end, file_size - 1) - if end < start: - raise HTTPException(status_code=416, detail="Requested range not satisfiable") - - content_length = end - start + 1 - headers = { - "Accept-Ranges": "bytes", - "Content-Range": f"bytes {start}-{end}/{file_size}", - "Content-Length": str(content_length), - } - return StreamingResponse( - _stream_file_range(target, start, end), - status_code=206, - media_type=media_type or "application/octet-stream", - headers=headers, + return workspace_service.write_markdown( + app_state=request.app.state, + bot=bot, + path=path, + content=str(payload.content or ""), ) - -def _build_workspace_raw_url(bot_id: str, path: str, public: bool) -> str: - normalized = "/".join(part for part in str(path or "").strip().split("/") if part) - if not normalized: - return "" - prefix = "/public" if public else "/api" - return f"{prefix}/bots/{quote(bot_id, safe='')}/workspace/raw/{quote(normalized, safe='/')}" - - def _serve_workspace_file( bot_id: str, path: str, @@ -4011,29 +4518,15 @@ def _serve_workspace_file( bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - _root, target = _resolve_workspace_path(bot_id, path) - if not os.path.isfile(target): - raise HTTPException(status_code=404, detail="File not found") - - media_type, _ = mimetypes.guess_type(target) - if redirect_html_to_raw and not download and str(media_type or "").startswith("text/html"): - raw_url = _build_workspace_raw_url(bot_id, path, public=public) - if raw_url: - return RedirectResponse(url=raw_url, status_code=307) - range_header = request.headers.get("range", "") - if range_header and not download: - return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header) - - common_headers = {"Accept-Ranges": "bytes"} - if download: - return FileResponse( - target, - media_type=media_type or "application/octet-stream", - filename=os.path.basename(target), - headers=common_headers, - ) - return FileResponse(target, media_type=media_type or "application/octet-stream", headers=common_headers) + return workspace_service.serve_file( + app_state=request.app.state, + bot=bot, + path=path, + download=download, + request=request, + public=public, + redirect_html_to_raw=redirect_html_to_raw, + ) @app.get("/api/bots/{bot_id}/cron/jobs") @@ -4173,79 +4666,13 @@ async def upload_workspace_files( bot_id: str, files: List[UploadFile] = File(...), path: Optional[str] = None, + request: Request = None, session: Session = Depends(get_session), ): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - if not files: - raise HTTPException(status_code=400, detail="no files uploaded") - platform_settings = get_platform_settings_snapshot() - max_bytes = platform_settings.upload_max_mb * 1024 * 1024 - allowed_extensions = set(platform_settings.allowed_attachment_extensions) - - root, upload_dir = _resolve_workspace_path(bot_id, path or "uploads") - os.makedirs(upload_dir, exist_ok=True) - safe_dir_real = os.path.abspath(upload_dir) - if os.path.commonpath([root, safe_dir_real]) != root: - raise HTTPException(status_code=400, detail="invalid upload target path") - - rows: List[Dict[str, Any]] = [] - for upload in files: - original = (upload.filename or "upload.bin").strip() or "upload.bin" - name = os.path.basename(original).replace("\\", "_").replace("/", "_") - name = re.sub(r"[^\w.\-()+@ ]+", "_", name) - if not name: - name = "upload.bin" - ext = str(os.path.splitext(name)[1] or "").strip().lower() - if allowed_extensions and ext not in allowed_extensions: - raise HTTPException( - status_code=400, - detail=f"File '{name}' extension is not allowed. Allowed: {', '.join(sorted(allowed_extensions))}", - ) - - abs_path = os.path.join(safe_dir_real, name) - if os.path.exists(abs_path): - base, ext = os.path.splitext(name) - name = f"{base}-{int(datetime.utcnow().timestamp())}{ext}" - abs_path = os.path.join(safe_dir_real, name) - - total_size = 0 - try: - with open(abs_path, "wb") as f: - while True: - chunk = await upload.read(1024 * 1024) - if not chunk: - break - total_size += len(chunk) - if total_size > max_bytes: - raise HTTPException( - status_code=413, - detail=f"File '{name}' too large (max {max_bytes // (1024 * 1024)}MB)", - ) - f.write(chunk) - except HTTPException: - if os.path.exists(abs_path): - os.remove(abs_path) - raise - except OSError as exc: - if os.path.exists(abs_path): - os.remove(abs_path) - raise HTTPException( - status_code=500, - detail=f"Failed to write file '{name}': {exc.strerror or str(exc)}", - ) - except Exception: - if os.path.exists(abs_path): - os.remove(abs_path) - raise HTTPException(status_code=500, detail=f"Failed to upload file '{name}'") - finally: - await upload.close() - - rel = os.path.relpath(abs_path, root).replace("\\", "/") - rows.append({"name": name, "path": rel, "size": total_size}) - - return {"bot_id": bot_id, "files": rows} + return await workspace_service.upload_files(app_state=request.app.state, bot=bot, files=files, path=path) @app.post("/api/bots/{bot_id}/speech/transcribe") @@ -4365,7 +4792,7 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str): pass return - docker_manager.ensure_monitor(bot_id, docker_callback) + runtime_service.ensure_monitor(app_state=websocket.app.state, bot=bot) try: while True: await websocket.receive_text() @@ -4383,7 +4810,7 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str): manager.disconnect(bot_id, websocket) -def _main_server_options() -> tuple[str, int, bool]: +def _main_server_options() -> tuple[str, int, bool, str, bool]: host = str(os.getenv("APP_HOST", "0.0.0.0") or "0.0.0.0").strip() or "0.0.0.0" try: port = int(os.getenv("APP_PORT", "8000")) @@ -4391,15 +4818,30 @@ def _main_server_options() -> tuple[str, int, bool]: port = 8000 port = max(1, min(port, 65535)) reload_flag = str(os.getenv("APP_RELOAD", "true")).strip().lower() in {"1", "true", "yes", "on"} - return host, port, reload_flag + log_level = str(os.getenv("APP_LOG_LEVEL", "warning") or "warning").strip().lower() or "warning" + access_log = str(os.getenv("APP_ACCESS_LOG", "false")).strip().lower() in {"1", "true", "yes", "on"} + return host, port, reload_flag, log_level, access_log if __name__ == "__main__": import uvicorn - host, port, reload_flag = _main_server_options() + host, port, reload_flag, log_level, access_log = _main_server_options() app_module = f"{os.path.splitext(os.path.basename(__file__))[0]}:app" if reload_flag: - uvicorn.run(app_module, host=host, port=port, reload=True) + uvicorn.run( + app_module, + host=host, + port=port, + reload=True, + log_level=log_level, + access_log=access_log, + ) else: - uvicorn.run(app, host=host, port=port) + uvicorn.run( + app, + host=host, + port=port, + log_level=log_level, + access_log=access_log, + ) diff --git a/backend/models/bot.py b/backend/models/bot.py index d7a16ac..398c829 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -14,6 +14,10 @@ class BotInstance(SQLModel, table=True): current_state: Optional[str] = Field(default="IDLE") last_action: Optional[str] = Field(default=None) image_tag: str = Field(default="nanobot-base:v0.1.4") # 记录该机器人使用的镜像版本 + node_id: str = Field(default="local", index=True) + transport_kind: str = Field(default="direct", index=True) + runtime_kind: str = Field(default="docker", index=True) + core_adapter: str = Field(default="nanobot", index=True) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/models/platform.py b/backend/models/platform.py index 59bd054..8bd5fc6 100644 --- a/backend/models/platform.py +++ b/backend/models/platform.py @@ -19,6 +19,25 @@ class PlatformSetting(SQLModel, table=True): updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) +class ManagedNodeRecord(SQLModel, table=True): + __tablename__ = "managed_node" + + node_id: str = Field(primary_key=True, max_length=120) + display_name: str = Field(default="", max_length=200) + base_url: str = Field(default="") + enabled: bool = Field(default=True, index=True) + auth_token: str = Field(default="") + transport_kind: str = Field(default="direct", max_length=32, index=True) + runtime_kind: str = Field(default="docker", max_length=32, index=True) + core_adapter: str = Field(default="nanobot", max_length=64, index=True) + metadata_json: str = Field(default="{}") + capabilities_json: str = Field(default="{}") + resources_json: str = Field(default="{}") + last_seen_at: Optional[datetime] = Field(default=None, index=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) + + class BotRequestUsage(SQLModel, table=True): __tablename__ = "bot_request_usage" diff --git a/backend/providers/__init__.py b/backend/providers/__init__.py new file mode 100644 index 0000000..3fb5062 --- /dev/null +++ b/backend/providers/__init__.py @@ -0,0 +1 @@ +# Provider package for runtime/workspace/provision abstractions. diff --git a/backend/providers/provision/__init__.py b/backend/providers/provision/__init__.py new file mode 100644 index 0000000..6146a63 --- /dev/null +++ b/backend/providers/provision/__init__.py @@ -0,0 +1,4 @@ +from providers.provision.base import ProvisionProvider +from providers.provision.local import LocalProvisionProvider + +__all__ = ["ProvisionProvider", "LocalProvisionProvider"] diff --git a/backend/providers/provision/base.py b/backend/providers/provision/base.py new file mode 100644 index 0000000..9158bdc --- /dev/null +++ b/backend/providers/provision/base.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from sqlmodel import Session + + +class ProvisionProvider(ABC): + @abstractmethod + def sync_bot_workspace( + self, + *, + session: Session, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + raise NotImplementedError diff --git a/backend/providers/provision/edge.py b/backend/providers/provision/edge.py new file mode 100644 index 0000000..cfa086d --- /dev/null +++ b/backend/providers/provision/edge.py @@ -0,0 +1,105 @@ +from typing import Any, Callable, Dict, List, Optional + +from fastapi import HTTPException +from sqlmodel import Session + +from clients.edge.base import EdgeClient +from models.bot import BotInstance +from providers.provision.base import ProvisionProvider +from providers.target import ProviderTarget + + +class EdgeProvisionProvider(ProvisionProvider): + def __init__( + self, + *, + read_provider_target: Callable[[str], ProviderTarget], + resolve_edge_client: Callable[[ProviderTarget], EdgeClient], + read_runtime_snapshot: Callable[[BotInstance], Dict[str, Any]], + read_bot_channels: Callable[[BotInstance], List[Dict[str, Any]]], + read_node_metadata: Callable[[str], Dict[str, Any]], + ) -> None: + self._read_provider_target = read_provider_target + self._resolve_edge_client = resolve_edge_client + self._read_runtime_snapshot = read_runtime_snapshot + self._read_bot_channels = read_bot_channels + self._read_node_metadata = read_node_metadata + + def sync_bot_workspace( + self, + *, + session: Session, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + bot = session.get(BotInstance, bot_id) + if bot is None: + raise HTTPException(status_code=404, detail="Bot not found") + snapshot = dict(self._read_runtime_snapshot(bot)) + merged_runtime = dict(snapshot) + if isinstance(runtime_overrides, dict): + for key, value in runtime_overrides.items(): + if key in {"api_key", "llm_provider", "llm_model"}: + text = str(value or "").strip() + if not text: + continue + merged_runtime[key] = text + continue + if key == "api_base": + merged_runtime[key] = str(value or "").strip() + continue + merged_runtime[key] = value + target = self._read_provider_target(bot_id) + merged_runtime.update(self._node_runtime_overrides(target.node_id, target.runtime_kind)) + + resolved_delivery = dict(global_delivery_override or {}) + if "sendProgress" not in resolved_delivery: + resolved_delivery["sendProgress"] = bool(merged_runtime.get("send_progress", False)) + if "sendToolHints" not in resolved_delivery: + resolved_delivery["sendToolHints"] = bool(merged_runtime.get("send_tool_hints", False)) + + self._client_for_target(target).sync_bot_workspace( + bot_id=bot_id, + channels_override=channels_override if channels_override is not None else self._read_bot_channels(bot), + global_delivery_override=resolved_delivery, + runtime_overrides=merged_runtime, + ) + + def _client_for_bot(self, bot_id: str) -> EdgeClient: + target = self._read_provider_target(bot_id) + return self._client_for_target(target) + + def _client_for_target(self, target: ProviderTarget) -> EdgeClient: + if target.transport_kind != "edge": + raise HTTPException(status_code=400, detail=f"edge provision provider requires edge transport, got {target.transport_kind}") + return self._resolve_edge_client(target) + + def _node_runtime_overrides(self, node_id: str, runtime_kind: str) -> Dict[str, str]: + metadata = dict(self._read_node_metadata(str(node_id or "").strip().lower()) or {}) + payload: Dict[str, str] = {} + workspace_root = str(metadata.get("workspace_root") or "").strip() + if workspace_root: + payload["workspace_root"] = workspace_root + if str(runtime_kind or "").strip().lower() != "native": + return payload + native_sandbox_mode = self._normalize_native_sandbox_mode(metadata.get("native_sandbox_mode")) + if native_sandbox_mode != "inherit": + payload["native_sandbox_mode"] = native_sandbox_mode + native_command = str(metadata.get("native_command") or "").strip() + native_workdir = str(metadata.get("native_workdir") or "").strip() + if native_command: + payload["native_command"] = native_command + if native_workdir: + payload["native_workdir"] = native_workdir + return payload + + @staticmethod + def _normalize_native_sandbox_mode(raw_value: Any) -> str: + text = str(raw_value or "").strip().lower() + if text in {"workspace", "sandbox", "strict"}: + return "workspace" + if text in {"full_access", "full-access", "danger-full-access", "escape"}: + return "full_access" + return "inherit" diff --git a/backend/providers/provision/local.py b/backend/providers/provision/local.py new file mode 100644 index 0000000..dbc6746 --- /dev/null +++ b/backend/providers/provision/local.py @@ -0,0 +1,34 @@ +from typing import Any, Callable, Dict, List, Optional + +from sqlmodel import Session + +from providers.provision.base import ProvisionProvider + + +class LocalProvisionProvider(ProvisionProvider): + def __init__( + self, + *, + sync_workspace_func: Callable[ + [Session, str, Optional[List[Dict[str, Any]]], Optional[Dict[str, Any]], Optional[Dict[str, Any]]], + None, + ], + ) -> None: + self._sync_workspace_func = sync_workspace_func + + def sync_bot_workspace( + self, + *, + session: Session, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + self._sync_workspace_func( + session, + bot_id, + channels_override, + global_delivery_override, + runtime_overrides, + ) diff --git a/backend/providers/registry.py b/backend/providers/registry.py new file mode 100644 index 0000000..de0e80a --- /dev/null +++ b/backend/providers/registry.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional + +from providers.target import ProviderTarget + +from providers.provision.base import ProvisionProvider +from providers.runtime.base import RuntimeProvider +from providers.workspace.base import WorkspaceProvider + + +@dataclass +class ProviderRegistry: + runtime: Dict[str, RuntimeProvider] = field(default_factory=dict) + workspace: Dict[str, WorkspaceProvider] = field(default_factory=dict) + provision: Dict[str, ProvisionProvider] = field(default_factory=dict) + + def register_bundle( + self, + *, + key: str, + runtime_provider: RuntimeProvider, + workspace_provider: WorkspaceProvider, + provision_provider: ProvisionProvider, + ) -> None: + self.runtime[key] = runtime_provider + self.workspace[key] = workspace_provider + self.provision[key] = provision_provider + + def resolve_bundle_key(self, target: ProviderTarget) -> Optional[str]: + exact = target.key + if exact in self.runtime and exact in self.workspace and exact in self.provision: + return exact + + for key in self.runtime.keys(): + if key not in self.workspace or key not in self.provision: + continue + parts = str(key or "").split(":") + if len(parts) < 4: + continue + _, transport_kind, runtime_kind, core_adapter = parts[0], parts[1], parts[2], ":".join(parts[3:]) + if ( + str(transport_kind or "").strip().lower() == str(target.transport_kind or "").strip().lower() + and str(runtime_kind or "").strip().lower() == str(target.runtime_kind or "").strip().lower() + and str(core_adapter or "").strip().lower() == str(target.core_adapter or "").strip().lower() + ): + return key + return None diff --git a/backend/providers/runtime/__init__.py b/backend/providers/runtime/__init__.py new file mode 100644 index 0000000..45bacb3 --- /dev/null +++ b/backend/providers/runtime/__init__.py @@ -0,0 +1,4 @@ +from providers.runtime.base import RuntimeProvider +from providers.runtime.local import LocalRuntimeProvider + +__all__ = ["RuntimeProvider", "LocalRuntimeProvider"] diff --git a/backend/providers/runtime/base.py b/backend/providers/runtime/base.py new file mode 100644 index 0000000..6da94f7 --- /dev/null +++ b/backend/providers/runtime/base.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from sqlmodel import Session + +from models.bot import BotInstance + + +class RuntimeProvider(ABC): + @abstractmethod + async def start_bot(self, *, session: Session, bot: BotInstance) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def stop_bot(self, *, session: Session, bot: BotInstance) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def deliver_command(self, *, bot_id: str, command: str, media: Optional[List[str]] = None) -> Optional[str]: + raise NotImplementedError + + @abstractmethod + def get_recent_logs(self, *, bot_id: str, tail: int = 300) -> List[str]: + raise NotImplementedError + + @abstractmethod + def ensure_monitor(self, *, bot_id: str) -> bool: + raise NotImplementedError + + @abstractmethod + def get_monitor_packets(self, *, bot_id: str, after_seq: int = 0, limit: int = 200) -> List[Dict[str, Any]]: + raise NotImplementedError + + @abstractmethod + def get_runtime_status(self, *, bot_id: str) -> str: + raise NotImplementedError + + @abstractmethod + def get_resource_snapshot(self, *, bot_id: str) -> Dict[str, Any]: + raise NotImplementedError diff --git a/backend/providers/runtime/edge.py b/backend/providers/runtime/edge.py new file mode 100644 index 0000000..c84b05a --- /dev/null +++ b/backend/providers/runtime/edge.py @@ -0,0 +1,136 @@ +from typing import Any, Callable, Dict, List, Optional + +from fastapi import HTTPException +from sqlmodel import Session + +from clients.edge.base import EdgeClient +from models.bot import BotInstance +from providers.runtime.base import RuntimeProvider +from providers.target import ProviderTarget, provider_target_to_dict + + +class EdgeRuntimeProvider(RuntimeProvider): + def __init__( + self, + *, + read_provider_target: Callable[[str], ProviderTarget], + resolve_edge_client: Callable[[ProviderTarget], EdgeClient], + read_runtime_snapshot: Callable[[BotInstance], Dict[str, Any]], + resolve_env_params: Callable[[str], Dict[str, str]], + read_bot_channels: Callable[[BotInstance], List[Dict[str, Any]]], + read_node_metadata: Callable[[str], Dict[str, Any]], + ) -> None: + self._read_provider_target = read_provider_target + self._resolve_edge_client = resolve_edge_client + self._read_runtime_snapshot = read_runtime_snapshot + self._resolve_env_params = resolve_env_params + self._read_bot_channels = read_bot_channels + self._read_node_metadata = read_node_metadata + + async def start_bot(self, *, session: Session, bot: BotInstance) -> Dict[str, Any]: + bot_id = str(bot.id or "").strip() + if not bot_id: + raise HTTPException(status_code=400, detail="Bot id is required") + if not bool(getattr(bot, "enabled", True)): + raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") + runtime_snapshot = self._read_runtime_snapshot(bot) + target = self._read_provider_target(bot_id) + client = self._client_for_target(target) + node_runtime_overrides = self._node_runtime_overrides(target.node_id, target.runtime_kind) + workspace_runtime = { + **dict(runtime_snapshot), + **provider_target_to_dict(target), + **node_runtime_overrides, + } + client.sync_bot_workspace( + bot_id=bot_id, + channels_override=self._read_bot_channels(bot), + global_delivery_override={ + "sendProgress": bool(runtime_snapshot.get("send_progress")), + "sendToolHints": bool(runtime_snapshot.get("send_tool_hints")), + }, + runtime_overrides=workspace_runtime, + ) + result = await client.start_bot( + bot=bot, + start_payload={ + "image_tag": bot.image_tag, + "runtime_kind": target.runtime_kind, + "env_vars": self._resolve_env_params(bot_id), + "cpu_cores": runtime_snapshot.get("cpu_cores"), + "memory_mb": runtime_snapshot.get("memory_mb"), + "storage_gb": runtime_snapshot.get("storage_gb"), + **node_runtime_overrides, + }, + ) + bot.docker_status = "RUNNING" + session.add(bot) + session.commit() + return result + + def stop_bot(self, *, session: Session, bot: BotInstance) -> Dict[str, Any]: + bot_id = str(bot.id or "").strip() + if not bot_id: + raise HTTPException(status_code=400, detail="Bot id is required") + if not bool(getattr(bot, "enabled", True)): + raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") + result = self._client_for_bot(bot_id).stop_bot(bot=bot) + bot.docker_status = "STOPPED" + session.add(bot) + session.commit() + return result + + def deliver_command(self, *, bot_id: str, command: str, media: Optional[List[str]] = None) -> Optional[str]: + return self._client_for_bot(bot_id).deliver_command(bot_id=bot_id, command=command, media=media) + + def get_recent_logs(self, *, bot_id: str, tail: int = 300) -> List[str]: + return self._client_for_bot(bot_id).get_recent_logs(bot_id=bot_id, tail=tail) + + def ensure_monitor(self, *, bot_id: str) -> bool: + return bool(self._client_for_bot(bot_id).ensure_monitor(bot_id=bot_id)) + + def get_monitor_packets(self, *, bot_id: str, after_seq: int = 0, limit: int = 200) -> List[Dict[str, Any]]: + return list(self._client_for_bot(bot_id).get_monitor_packets(bot_id=bot_id, after_seq=after_seq, limit=limit) or []) + + def get_runtime_status(self, *, bot_id: str) -> str: + return str(self._client_for_bot(bot_id).get_runtime_status(bot_id=bot_id) or "STOPPED").upper() + + def get_resource_snapshot(self, *, bot_id: str) -> Dict[str, Any]: + return dict(self._client_for_bot(bot_id).get_resource_snapshot(bot_id=bot_id) or {}) + + def _client_for_bot(self, bot_id: str) -> EdgeClient: + target = self._read_provider_target(bot_id) + return self._client_for_target(target) + + def _client_for_target(self, target: ProviderTarget) -> EdgeClient: + if target.transport_kind != "edge": + raise HTTPException(status_code=400, detail=f"edge runtime provider requires edge transport, got {target.transport_kind}") + return self._resolve_edge_client(target) + + def _node_runtime_overrides(self, node_id: str, runtime_kind: str) -> Dict[str, str]: + metadata = dict(self._read_node_metadata(str(node_id or "").strip().lower()) or {}) + payload: Dict[str, str] = {} + workspace_root = str(metadata.get("workspace_root") or "").strip() + if workspace_root: + payload["workspace_root"] = workspace_root + if str(runtime_kind or "").strip().lower() != "native": + return payload + native_sandbox_mode = self._normalize_native_sandbox_mode(metadata.get("native_sandbox_mode")) + if native_sandbox_mode != "inherit": + payload["native_sandbox_mode"] = native_sandbox_mode + native_command = str(metadata.get("native_command") or "").strip() + native_workdir = str(metadata.get("native_workdir") or "").strip() + if native_command: + payload["native_command"] = native_command + if native_workdir: + payload["native_workdir"] = native_workdir + return payload + + @staticmethod + def _normalize_native_sandbox_mode(raw_value: Any) -> str: + text = str(raw_value or "").strip().lower() + if text in {"workspace", "sandbox", "strict"}: + return "workspace" + if text in {"full_access", "full-access", "danger-full-access", "escape"}: + return "full_access" + return "inherit" diff --git a/backend/providers/runtime/local.py b/backend/providers/runtime/local.py new file mode 100644 index 0000000..05e5110 --- /dev/null +++ b/backend/providers/runtime/local.py @@ -0,0 +1,117 @@ +import asyncio +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from fastapi import HTTPException +from sqlmodel import Session + +from models.bot import BotInstance +from providers.provision.base import ProvisionProvider +from providers.runtime.base import RuntimeProvider +from services.platform_service import record_activity_event + + +class LocalRuntimeProvider(RuntimeProvider): + def __init__( + self, + *, + docker_manager: Any, + on_state_change: Callable[[str, dict], None], + provision_provider: ProvisionProvider, + read_runtime_snapshot: Callable[[BotInstance], Dict[str, Any]], + resolve_env_params: Callable[[str], Dict[str, str]], + write_env_store: Callable[[str, Dict[str, str]], None], + invalidate_bot_cache: Callable[[str], None], + record_agent_loop_ready_warning: Callable[[str], Awaitable[None]], + safe_float: Callable[[Any, float], float], + safe_int: Callable[[Any, int], int], + ) -> None: + self._docker_manager = docker_manager + self._on_state_change = on_state_change + self._provision_provider = provision_provider + self._read_runtime_snapshot = read_runtime_snapshot + self._resolve_env_params = resolve_env_params + self._write_env_store = write_env_store + self._invalidate_bot_cache = invalidate_bot_cache + self._record_agent_loop_ready_warning = record_agent_loop_ready_warning + self._safe_float = safe_float + self._safe_int = safe_int + + async def start_bot(self, *, session: Session, bot: BotInstance) -> Dict[str, Any]: + bot_id = str(bot.id or "").strip() + if not bot_id: + raise HTTPException(status_code=400, detail="Bot id is required") + if not bool(getattr(bot, "enabled", True)): + raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") + + self._provision_provider.sync_bot_workspace(session=session, bot_id=bot_id) + runtime_snapshot = self._read_runtime_snapshot(bot) + env_params = self._resolve_env_params(bot_id) + self._write_env_store(bot_id, env_params) + success = self._docker_manager.start_bot( + bot_id, + image_tag=bot.image_tag, + on_state_change=self._on_state_change, + env_vars=env_params, + cpu_cores=self._safe_float(runtime_snapshot.get("cpu_cores"), 1.0), + memory_mb=self._safe_int(runtime_snapshot.get("memory_mb"), 1024), + storage_gb=self._safe_int(runtime_snapshot.get("storage_gb"), 10), + ) + if not success: + bot.docker_status = "STOPPED" + session.add(bot) + session.commit() + raise HTTPException(status_code=500, detail=f"Failed to start container with image {bot.image_tag}") + + actual_status = self._docker_manager.get_bot_status(bot_id) + bot.docker_status = actual_status + if actual_status != "RUNNING": + session.add(bot) + session.commit() + self._invalidate_bot_cache(bot_id) + raise HTTPException( + status_code=500, + detail="Bot container failed shortly after startup. Check bot logs/config.", + ) + + asyncio.create_task(self._record_agent_loop_ready_warning(bot_id)) + session.add(bot) + record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}") + session.commit() + self._invalidate_bot_cache(bot_id) + return {"status": "started"} + + def stop_bot(self, *, session: Session, bot: BotInstance) -> Dict[str, Any]: + bot_id = str(bot.id or "").strip() + if not bot_id: + raise HTTPException(status_code=400, detail="Bot id is required") + if not bool(getattr(bot, "enabled", True)): + raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") + + self._docker_manager.stop_bot(bot_id) + bot.docker_status = "STOPPED" + session.add(bot) + record_activity_event(session, bot_id, "bot_stopped", channel="system", detail=f"Container stopped for {bot_id}") + session.commit() + self._invalidate_bot_cache(bot_id) + return {"status": "stopped"} + + def deliver_command(self, *, bot_id: str, command: str, media: Optional[List[str]] = None) -> Optional[str]: + success = self._docker_manager.send_command(bot_id, command, media=media) + if success: + return None + return self._docker_manager.get_last_delivery_error(bot_id) or "command delivery failed" + + def get_recent_logs(self, *, bot_id: str, tail: int = 300) -> List[str]: + return list(self._docker_manager.get_recent_logs(bot_id, tail=tail) or []) + + def ensure_monitor(self, *, bot_id: str) -> bool: + return bool(self._docker_manager.ensure_monitor(bot_id, self._on_state_change)) + + def get_monitor_packets(self, *, bot_id: str, after_seq: int = 0, limit: int = 200) -> List[Dict[str, Any]]: + return [] + + def get_runtime_status(self, *, bot_id: str) -> str: + return str(self._docker_manager.get_bot_status(bot_id) or "STOPPED").upper() + + def get_resource_snapshot(self, *, bot_id: str) -> Dict[str, Any]: + return dict(self._docker_manager.get_bot_resource_snapshot(bot_id) or {}) diff --git a/backend/providers/selector.py b/backend/providers/selector.py new file mode 100644 index 0000000..59cd288 --- /dev/null +++ b/backend/providers/selector.py @@ -0,0 +1,59 @@ +from typing import Any + +from models.bot import BotInstance +from providers.registry import ProviderRegistry +from providers.provision.base import ProvisionProvider +from providers.runtime.base import RuntimeProvider +from providers.target import resolve_provider_target +from providers.workspace.base import WorkspaceProvider + + +def _require_provider(value: Any, label: str): + if value is None: + raise RuntimeError(f"{label} is not configured") + return value + + +def _get_registry(app_state: Any) -> ProviderRegistry | None: + registry = getattr(app_state, "provider_registry", None) + if registry is None: + return None + if not isinstance(registry, ProviderRegistry): + raise RuntimeError("provider registry is misconfigured") + return registry + + +def get_runtime_provider(app_state: Any, bot: BotInstance) -> RuntimeProvider: + registry = _get_registry(app_state) + if registry is not None: + target = resolve_provider_target(app_state, bot) + bundle_key = registry.resolve_bundle_key(target) + provider = registry.runtime.get(bundle_key) if bundle_key else None + if provider is not None: + return provider + raise RuntimeError(f"runtime provider is not configured for target {target.key}") + return _require_provider(getattr(app_state, "runtime_provider", None), "runtime provider") + + +def get_workspace_provider(app_state: Any, bot: BotInstance) -> WorkspaceProvider: + registry = _get_registry(app_state) + if registry is not None: + target = resolve_provider_target(app_state, bot) + bundle_key = registry.resolve_bundle_key(target) + provider = registry.workspace.get(bundle_key) if bundle_key else None + if provider is not None: + return provider + raise RuntimeError(f"workspace provider is not configured for target {target.key}") + return _require_provider(getattr(app_state, "workspace_provider", None), "workspace provider") + + +def get_provision_provider(app_state: Any, bot: BotInstance) -> ProvisionProvider: + registry = _get_registry(app_state) + if registry is not None: + target = resolve_provider_target(app_state, bot) + bundle_key = registry.resolve_bundle_key(target) + provider = registry.provision.get(bundle_key) if bundle_key else None + if provider is not None: + return provider + raise RuntimeError(f"provision provider is not configured for target {target.key}") + return _require_provider(getattr(app_state, "provision_provider", None), "provision provider") diff --git a/backend/providers/target.py b/backend/providers/target.py new file mode 100644 index 0000000..c35521a --- /dev/null +++ b/backend/providers/target.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from typing import Any + +from models.bot import BotInstance + +DEFAULT_NODE_ID = "local" +DEFAULT_TRANSPORT_KIND = "direct" +DEFAULT_RUNTIME_KIND = "docker" +DEFAULT_CORE_ADAPTER = "nanobot" +TARGET_CONFIG_KEY = "dashboardRuntime" +SUPPORTED_TRANSPORT_KINDS = {"direct", "edge"} +SUPPORTED_RUNTIME_KINDS = {"docker", "native"} + + +def _normalize_target_part(value: Any, fallback: str) -> str: + text = str(value or "").strip().lower() + return text or fallback + + +@dataclass(frozen=True) +class ProviderTarget: + node_id: str = DEFAULT_NODE_ID + transport_kind: str = DEFAULT_TRANSPORT_KIND + runtime_kind: str = DEFAULT_RUNTIME_KIND + core_adapter: str = DEFAULT_CORE_ADAPTER + + @property + def key(self) -> str: + return ":".join([self.node_id, self.transport_kind, self.runtime_kind, self.core_adapter]) + + +def normalize_provider_target(value: Any, fallback: ProviderTarget | None = None) -> ProviderTarget: + base = fallback or ProviderTarget() + if isinstance(value, ProviderTarget): + raw_node_id = value.node_id + raw_transport_kind = value.transport_kind + raw_runtime_kind = value.runtime_kind + raw_core_adapter = value.core_adapter + elif isinstance(value, dict): + raw_node_id = value.get("node_id", value.get("nodeId")) + raw_transport_kind = value.get("transport_kind", value.get("transportKind")) + raw_runtime_kind = value.get("runtime_kind", value.get("runtimeKind")) + raw_core_adapter = value.get("core_adapter", value.get("coreAdapter")) + else: + raw_node_id = None + raw_transport_kind = None + raw_runtime_kind = None + raw_core_adapter = None + + transport_kind = _normalize_target_part(raw_transport_kind, base.transport_kind) + if transport_kind not in SUPPORTED_TRANSPORT_KINDS: + transport_kind = base.transport_kind + + runtime_kind = _normalize_target_part(raw_runtime_kind, base.runtime_kind) + if runtime_kind not in SUPPORTED_RUNTIME_KINDS: + runtime_kind = base.runtime_kind + + return ProviderTarget( + node_id=_normalize_target_part(raw_node_id, base.node_id), + transport_kind=transport_kind, + runtime_kind=runtime_kind, + core_adapter=_normalize_target_part(raw_core_adapter, base.core_adapter), + ) + + +def provider_target_to_dict(target: ProviderTarget) -> dict[str, str]: + return { + "node_id": target.node_id, + "transport_kind": target.transport_kind, + "runtime_kind": target.runtime_kind, + "core_adapter": target.core_adapter, + } + + +def provider_target_from_config(config_data: Any, fallback: ProviderTarget | None = None) -> ProviderTarget: + if not isinstance(config_data, dict): + return normalize_provider_target(None, fallback=fallback) + return normalize_provider_target(config_data.get(TARGET_CONFIG_KEY), fallback=fallback) + + +def write_provider_target_config(config_data: dict[str, Any], target: ProviderTarget) -> dict[str, Any]: + config_data[TARGET_CONFIG_KEY] = { + "nodeId": target.node_id, + "transportKind": target.transport_kind, + "runtimeKind": target.runtime_kind, + "coreAdapter": target.core_adapter, + } + return config_data + + +def resolve_provider_target(app_state: Any, bot: BotInstance) -> ProviderTarget: + fallback = ProviderTarget( + node_id=_normalize_target_part(getattr(app_state, "provider_default_node_id", None), DEFAULT_NODE_ID), + transport_kind=_normalize_target_part( + getattr(app_state, "provider_default_transport_kind", None), + DEFAULT_TRANSPORT_KIND, + ), + runtime_kind=_normalize_target_part( + getattr(app_state, "provider_default_runtime_kind", None), + DEFAULT_RUNTIME_KIND, + ), + core_adapter=_normalize_target_part( + getattr(app_state, "provider_default_core_adapter", None), + DEFAULT_CORE_ADAPTER, + ), + ) + resolver = getattr(app_state, "resolve_bot_provider_target", None) + if callable(resolver): + return normalize_provider_target(resolver(bot), fallback=fallback) + return normalize_provider_target( + { + "node_id": getattr(bot, "node_id", None), + "transport_kind": getattr(bot, "transport_kind", None), + "runtime_kind": getattr(bot, "runtime_kind", None), + "core_adapter": getattr(bot, "core_adapter", None), + }, + fallback=fallback, + ) diff --git a/backend/schemas/edge.py b/backend/schemas/edge.py new file mode 100644 index 0000000..9cdfefa --- /dev/null +++ b/backend/schemas/edge.py @@ -0,0 +1,128 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +NODE_PROTOCOL_VERSION = "1" + + +class EdgeNodeIdentityBase(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str + display_name: str + service: str = "dashboard-edge" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + + +class EdgeStatusResponse(BaseModel): + status: str + + +class EdgeStateWriteRequest(BaseModel): + data: Dict[str, Any] = Field(default_factory=dict) + workspace_root: Optional[str] = None + + +class EdgeStateResponse(BaseModel): + bot_id: str + state_key: str + data: Dict[str, Any] = Field(default_factory=dict) + + +class EdgeNativePreflightRequest(BaseModel): + native_command: Optional[str] = None + native_workdir: Optional[str] = None + + +class EdgeNativePreflightResponse(BaseModel): + ok: bool = False + command: List[str] = Field(default_factory=list) + workdir: str = "" + command_available: bool = False + workdir_exists: bool = False + detail: str = "" + + +class EdgeStartBotRequest(BaseModel): + image_tag: str + runtime_kind: str = "docker" + env_vars: Dict[str, str] = Field(default_factory=dict) + workspace_root: Optional[str] = None + native_command: Optional[str] = None + native_workdir: Optional[str] = None + cpu_cores: float = 1.0 + memory_mb: int = 1024 + storage_gb: int = 10 + + +class EdgeCommandRequest(BaseModel): + command: str + media: List[str] = Field(default_factory=list) + + +class EdgeLogsResponse(BaseModel): + bot_id: str + logs: List[str] = Field(default_factory=list) + + +class EdgeMonitorEnsureResponse(BaseModel): + ensured: bool = False + + +class EdgeMonitorPacket(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str = "" + bot_id: str = "" + seq: int = 0 + captured_at: str = "" + packet: Dict[str, Any] = Field(default_factory=dict) + + +class EdgeMonitorPacketsResponse(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str = "" + bot_id: str + latest_seq: int = 0 + packets: List[EdgeMonitorPacket] = Field(default_factory=list) + + +class EdgeWorkspaceSyncRequest(BaseModel): + channels_override: Optional[List[Dict[str, Any]]] = None + global_delivery_override: Optional[Dict[str, Any]] = None + runtime_overrides: Optional[Dict[str, Any]] = None + + +class EdgeMarkdownWriteRequest(BaseModel): + content: str = "" + + +class EdgeNodeResourcesResponse(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str + display_name: str = "" + service: str = "dashboard-edge" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + resources: Dict[str, Any] = Field(default_factory=dict) + reported_at: str = "" + + +class EdgeNodeSelfResponse(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str + display_name: str + service: str = "dashboard-edge" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + capabilities: Dict[str, Any] = Field(default_factory=dict) + resources: Dict[str, Any] = Field(default_factory=dict) + reported_at: str = "" + + +class EdgeNodeHeartbeatResponse(EdgeNodeIdentityBase): + capabilities: Dict[str, Any] = Field(default_factory=dict) + resources: Dict[str, Any] = Field(default_factory=dict) + reported_at: str = "" diff --git a/backend/schemas/platform.py b/backend/schemas/platform.py index 4fddb04..86501d2 100644 --- a/backend/schemas/platform.py +++ b/backend/schemas/platform.py @@ -97,3 +97,39 @@ class SystemSettingItem(BaseModel): sort_order: int = 100 created_at: str updated_at: str + + +class ManagedNodePayload(BaseModel): + node_id: str + display_name: str = "" + base_url: str = "" + enabled: bool = True + auth_token: str = "" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + workspace_root: str = "" + native_command: str = "" + native_workdir: str = "" + native_sandbox_mode: str = "inherit" + + +class ManagedNodeConnectivityResult(BaseModel): + ok: bool + status: str + latency_ms: int = 0 + detail: str = "" + node_self: Optional[Dict[str, Any]] = None + + +class ManagedNodeNativePreflightResult(BaseModel): + ok: bool + status: str + latency_ms: int = 0 + detail: str = "" + command: List[str] = Field(default_factory=list) + workdir: str = "" + command_available: bool = False + workdir_exists: bool = False + runtime_native_supported: bool = False + node_self: Optional[Dict[str, Any]] = None diff --git a/backend/services/bot_command_service.py b/backend/services/bot_command_service.py new file mode 100644 index 0000000..996cba5 --- /dev/null +++ b/backend/services/bot_command_service.py @@ -0,0 +1,333 @@ +import asyncio +import os +import threading +import time +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional + +from fastapi import HTTPException +from sqlmodel import Session + +from models.bot import BotInstance +from providers.runtime.base import RuntimeProvider + + +class BotCommandService: + def __init__( + self, + *, + read_runtime_snapshot: Callable[[BotInstance], Dict[str, Any]], + normalize_media_list: Callable[[Any, str], List[str]], + resolve_workspace_path: Callable[[str, Optional[str]], tuple[str, str]], + is_visual_attachment_path: Callable[[str], bool], + is_video_attachment_path: Callable[[str], bool], + create_usage_request: Callable[..., str], + record_activity_event: Callable[..., None], + fail_latest_usage: Callable[[Session, str, str], None], + persist_runtime_packet: Callable[[str, Dict[str, Any]], Optional[int]], + get_main_loop: Callable[[Any], Any], + broadcast_packet: Callable[[str, Dict[str, Any], Any], None], + ) -> None: + self._read_runtime_snapshot = read_runtime_snapshot + self._normalize_media_list = normalize_media_list + self._resolve_workspace_path = resolve_workspace_path + self._is_visual_attachment_path = is_visual_attachment_path + self._is_video_attachment_path = is_video_attachment_path + self._create_usage_request = create_usage_request + self._record_activity_event = record_activity_event + self._fail_latest_usage = fail_latest_usage + self._persist_runtime_packet = persist_runtime_packet + self._get_main_loop = get_main_loop + self._broadcast_packet = broadcast_packet + self._monitor_sync_threads: Dict[tuple[str, str], threading.Thread] = {} + self._monitor_sync_lock = threading.Lock() + self._monitor_sync_seq_lock = threading.Lock() + self._monitor_sync_last_seq: Dict[str, int] = {} + + def execute( + self, + *, + session: Session, + bot_id: str, + bot: BotInstance, + payload: Any, + runtime_provider: RuntimeProvider, + app_state: Any, + ) -> Dict[str, Any]: + runtime_snapshot = self._read_runtime_snapshot(bot) + attachments = self._normalize_media_list(getattr(payload, "attachments", None), bot_id) + command = str(getattr(payload, "command", None) or "").strip() + if not command and not attachments: + raise HTTPException(status_code=400, detail="Command or attachments is required") + + checked_attachments: List[str] = [] + transport_kind = str(getattr(bot, "transport_kind", "") or "").strip().lower() + for rel in attachments: + if transport_kind != "edge": + _, target = self._resolve_workspace_path(bot_id, rel) + if not os.path.isfile(target): + raise HTTPException(status_code=400, detail=f"attachment not found: {rel}") + checked_attachments.append(rel) + delivery_media = [f"/root/.nanobot/workspace/{p.lstrip('/')}" for p in checked_attachments] + + display_command = command if command else "[attachment message]" + delivery_command = self._build_delivery_command(command=command, checked_attachments=checked_attachments) + + request_id = self._create_usage_request( + session, + bot_id, + display_command, + attachments=checked_attachments, + channel="dashboard", + metadata={"attachment_count": len(checked_attachments)}, + provider=str(runtime_snapshot.get("llm_provider") or "").strip() or None, + model=str(runtime_snapshot.get("llm_model") or "").strip() or None, + ) + self._record_activity_event( + session, + bot_id, + "command_submitted", + request_id=request_id, + channel="dashboard", + detail="command submitted", + metadata={ + "attachment_count": len(checked_attachments), + "has_text": bool(command), + }, + ) + session.commit() + + outbound_user_packet: Optional[Dict[str, Any]] = None + if display_command or checked_attachments: + outbound_user_packet = { + "type": "USER_COMMAND", + "channel": "dashboard", + "text": display_command, + "media": checked_attachments, + "request_id": request_id, + } + self._persist_runtime_packet(bot_id, outbound_user_packet) + + loop = self._get_main_loop(app_state) + if loop and loop.is_running() and outbound_user_packet: + self._broadcast_packet(bot_id, outbound_user_packet, loop) + + detail = runtime_provider.deliver_command(bot_id=bot_id, command=delivery_command, media=delivery_media) + if detail is not None: + self._fail_latest_usage(session, bot_id, detail or "command delivery failed") + self._record_activity_event( + session, + bot_id, + "command_failed", + request_id=request_id, + channel="dashboard", + detail=(detail or "command delivery failed")[:400], + ) + session.commit() + if loop and loop.is_running(): + self._broadcast_packet( + bot_id, + { + "type": "AGENT_STATE", + "channel": "dashboard", + "payload": { + "state": "ERROR", + "action_msg": detail or "command delivery failed", + }, + }, + loop, + ) + raise HTTPException( + status_code=502, + detail=f"Failed to deliver command to bot dashboard channel{': ' + detail if detail else ''}", + ) + + self._maybe_sync_edge_monitor_packets( + runtime_provider=runtime_provider, + bot_id=bot_id, + request_id=request_id, + after_seq=self._resolve_monitor_baseline_seq(runtime_provider, bot_id), + app_state=app_state, + ) + return {"success": True} + + def _maybe_sync_edge_monitor_packets( + self, + *, + runtime_provider: RuntimeProvider, + bot_id: str, + request_id: str, + after_seq: int, + app_state: Any, + ) -> None: + provider_name = runtime_provider.__class__.__name__.strip().lower() + if provider_name != "edgeruntimeprovider": + return + bot_key = str(bot_id or "").strip() + if not bot_key: + return + request_key = str(request_id or "").strip() or f"seq:{int(after_seq or 0)}" + thread_key = (bot_key, request_key) + with self._monitor_sync_lock: + existing = self._monitor_sync_threads.get(thread_key) + if existing and existing.is_alive(): + return + thread = threading.Thread( + target=self._sync_edge_monitor_packets, + args=(runtime_provider, bot_key, request_id, after_seq, app_state), + daemon=True, + ) + self._monitor_sync_threads[thread_key] = thread + thread.start() + + def sync_edge_monitor_packets( + self, + *, + runtime_provider: RuntimeProvider, + bot_id: str, + request_id: str, + app_state: Any, + ) -> None: + self._maybe_sync_edge_monitor_packets( + runtime_provider=runtime_provider, + bot_id=bot_id, + request_id=request_id, + after_seq=0, + app_state=app_state, + ) + + def _sync_edge_monitor_packets( + self, + runtime_provider: RuntimeProvider, + bot_id: str, + request_id: str, + after_seq: int, + app_state: Any, + ) -> None: + loop = self._get_main_loop(app_state) + last_seq = max(0, int(after_seq or 0)) + deadline = time.monotonic() + 18.0 + request_id_norm = str(request_id or "").strip() + try: + while time.monotonic() < deadline: + try: + rows = runtime_provider.get_monitor_packets(bot_id=bot_id, after_seq=last_seq, limit=200) + except Exception: + time.sleep(0.5) + continue + + for row in rows or []: + try: + seq = int(row.get("seq") or 0) + except Exception: + seq = 0 + + packet = dict(row.get("packet") or {}) + if not packet: + continue + packet_type = str(packet.get("type") or "").strip().upper() + packet_request_id = str(packet.get("request_id") or "").strip() + if packet_type == "USER_COMMAND": + continue + if packet_type in {"ASSISTANT_MESSAGE", "BUS_EVENT"} and request_id_norm and packet_request_id and packet_request_id != request_id_norm: + continue + + if not self._mark_monitor_seq(bot_id, seq): + continue + last_seq = max(last_seq, seq) + + self._persist_runtime_packet(bot_id, packet) + if loop and loop.is_running(): + self._broadcast_packet(bot_id, packet, loop) + time.sleep(0.5) + finally: + with self._monitor_sync_lock: + request_key = request_id_norm or f"seq:{int(after_seq or 0)}" + existing = self._monitor_sync_threads.get((bot_id, request_key)) + if existing is threading.current_thread(): + self._monitor_sync_threads.pop((bot_id, request_key), None) + + def _resolve_monitor_baseline_seq(self, runtime_provider: RuntimeProvider, bot_id: str) -> int: + try: + rows = runtime_provider.get_monitor_packets(bot_id=bot_id, after_seq=0, limit=1000) + except Exception: + return self._get_monitor_seq(bot_id) + latest_seq = 0 + for row in rows or []: + try: + seq = int(row.get("seq") or 0) + except Exception: + seq = 0 + latest_seq = max(latest_seq, seq) + return max(latest_seq, self._get_monitor_seq(bot_id)) + + def _mark_monitor_seq(self, bot_id: str, seq: int) -> bool: + if seq <= 0: + return False + bot_key = str(bot_id or "").strip() + with self._monitor_sync_seq_lock: + current = int(self._monitor_sync_last_seq.get(bot_key, 0) or 0) + if seq <= current: + return False + self._monitor_sync_last_seq[bot_key] = seq + return True + + def _get_monitor_seq(self, bot_id: str) -> int: + bot_key = str(bot_id or "").strip() + with self._monitor_sync_seq_lock: + return int(self._monitor_sync_last_seq.get(bot_key, 0) or 0) + + def _build_delivery_command(self, *, command: str, checked_attachments: List[str]) -> str: + display_command = command if command else "[attachment message]" + delivery_command = display_command + if not checked_attachments: + return delivery_command + + attachment_block = "\n".join(f"- {p}" for p in checked_attachments) + all_visual = all(self._is_visual_attachment_path(p) for p in checked_attachments) + if all_visual: + has_video = any(self._is_video_attachment_path(p) for p in checked_attachments) + media_label = "图片/视频" if has_video else "图片" + capability_hint = ( + "1) 附件已随请求附带;图片在可用时可直接作为多模态输入理解,视频请按附件路径处理。\n" + if has_video + else "1) 附件中的图片已作为多模态输入提供,优先直接理解并回答。\n" + ) + if command: + return ( + f"{command}\n\n" + "[Attached files]\n" + f"{attachment_block}\n\n" + "【附件处理要求】\n" + f"{capability_hint}" + "2) 若当前模型或接口不支持直接理解该附件,请明确说明后再调用工具解析。\n" + "3) 除非用户明确要求,不要先调用工具读取附件文件。\n" + "4) 回复语言必须遵循 USER.md;若未指定,则与用户当前输入语言保持一致。\n" + "5) 仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。" + ) + return ( + "请先处理已附带的附件列表:\n" + f"{attachment_block}\n\n" + f"请直接分析已附带的{media_label}并总结关键信息。\n" + f"{'图片在可用时可直接作为多模态输入理解,视频请按附件路径处理。' if has_video else ''}\n" + "若当前模型或接口不支持直接理解该附件,请明确说明后再调用工具解析。\n" + "回复语言必须遵循 USER.md;若未指定,则与用户当前输入语言保持一致。\n" + "仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。" + ) + + command_has_paths = all(p in command for p in checked_attachments) if command else False + if command and not command_has_paths: + return ( + f"{command}\n\n" + "[Attached files]\n" + f"{attachment_block}\n\n" + "Please process the attached file(s) listed above when answering this request.\n" + "Reply language must follow USER.md. If not specified, use the same language as the user input." + ) + if not command: + return ( + "Please process the uploaded file(s) listed below:\n" + f"{attachment_block}\n\n" + "Reply language must follow USER.md. If not specified, use the same language as the user input." + ) + return delivery_command diff --git a/backend/services/node_registry_service.py b/backend/services/node_registry_service.py new file mode 100644 index 0000000..bb8bcf8 --- /dev/null +++ b/backend/services/node_registry_service.py @@ -0,0 +1,181 @@ +import json +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlmodel import Session, select + +from models.platform import ManagedNodeRecord + + +@dataclass(frozen=True) +class ManagedNode: + node_id: str + display_name: str + base_url: str = "" + enabled: bool = True + auth_token: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + capabilities: Dict[str, Any] = field(default_factory=dict) + resources: Dict[str, Any] = field(default_factory=dict) + last_seen_at: Optional[str] = None + + +class NodeRegistryService: + def __init__(self) -> None: + self._nodes: Dict[str, ManagedNode] = {} + + def register_node(self, node: ManagedNode) -> None: + self._nodes[str(node.node_id or "").strip().lower()] = self._normalize_node(node) + + def list_nodes(self) -> List[ManagedNode]: + return [self._nodes[key] for key in sorted(self._nodes.keys())] + + def get_node(self, node_id: str) -> Optional[ManagedNode]: + key = str(node_id or "").strip().lower() + if not key: + return None + return self._nodes.get(key) + + def require_node(self, node_id: str) -> ManagedNode: + node = self.get_node(node_id) + if node is None: + raise ValueError(f"Managed node not found: {node_id}") + if not node.enabled: + raise ValueError(f"Managed node is disabled: {node_id}") + return node + + def load_from_session(self, session: Session) -> List[ManagedNode]: + rows = session.exec(select(ManagedNodeRecord)).all() + self._nodes = {} + for row in rows: + self.register_node(self._row_to_node(row)) + return self.list_nodes() + + def upsert_node(self, session: Session, node: ManagedNode) -> ManagedNode: + normalized = self._normalize_node(node) + row = session.get(ManagedNodeRecord, normalized.node_id) + if row is None: + row = ManagedNodeRecord(node_id=normalized.node_id) + metadata = dict(normalized.metadata or {}) + row.display_name = normalized.display_name or normalized.node_id + row.base_url = normalized.base_url or "" + row.enabled = bool(normalized.enabled) + row.auth_token = normalized.auth_token or "" + row.transport_kind = str(metadata.get("transport_kind") or "edge").strip().lower() or "edge" + row.runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower() or "docker" + row.core_adapter = str(metadata.get("core_adapter") or "nanobot").strip().lower() or "nanobot" + row.metadata_json = json.dumps(metadata, ensure_ascii=False, sort_keys=True) + row.capabilities_json = json.dumps(dict(normalized.capabilities or {}), ensure_ascii=False, sort_keys=True) + row.resources_json = json.dumps(dict(normalized.resources or {}), ensure_ascii=False, sort_keys=True) + row.last_seen_at = self._parse_datetime(normalized.last_seen_at) or row.last_seen_at + row.updated_at = datetime.utcnow() + if row.created_at is None: + row.created_at = datetime.utcnow() + session.add(row) + session.commit() + session.refresh(row) + self.register_node(self._row_to_node(row)) + return self.require_node(normalized.node_id) + + def mark_node_seen( + self, + session: Session, + *, + node_id: str, + display_name: Optional[str] = None, + capabilities: Optional[Dict[str, Any]] = None, + resources: Optional[Dict[str, Any]] = None, + ) -> ManagedNode: + row = session.get(ManagedNodeRecord, str(node_id or "").strip().lower()) + if row is None: + raise ValueError(f"Managed node not found: {node_id}") + if str(display_name or "").strip(): + row.display_name = str(display_name).strip() + if capabilities is not None: + row.capabilities_json = json.dumps(dict(capabilities or {}), ensure_ascii=False, sort_keys=True) + if resources is not None: + row.resources_json = json.dumps(dict(resources or {}), ensure_ascii=False, sort_keys=True) + row.last_seen_at = datetime.utcnow() + row.updated_at = datetime.utcnow() + session.add(row) + session.commit() + session.refresh(row) + self.register_node(self._row_to_node(row)) + return self.require_node(str(node_id or "").strip().lower()) + + def delete_node(self, session: Session, node_id: str) -> None: + key = str(node_id or "").strip().lower() + if not key: + raise ValueError("node_id is required") + row = session.get(ManagedNodeRecord, key) + if row is None: + raise ValueError(f"Managed node not found: {node_id}") + session.delete(row) + session.commit() + self._nodes.pop(key, None) + + @staticmethod + def _normalize_node(node: ManagedNode) -> ManagedNode: + metadata = dict(node.metadata or {}) + normalized = ManagedNode( + node_id=str(node.node_id or "").strip().lower(), + display_name=str(node.display_name or node.node_id or "").strip() or str(node.node_id or "").strip().lower(), + base_url=str(node.base_url or "").strip(), + enabled=bool(node.enabled), + auth_token=str(node.auth_token or "").strip(), + metadata=metadata, + capabilities=dict(node.capabilities or {}), + resources=dict(node.resources or {}), + last_seen_at=str(node.last_seen_at or "").strip() or None, + ) + return normalized + + @staticmethod + def _row_to_node(row: ManagedNodeRecord) -> ManagedNode: + metadata: Dict[str, Any] = {} + capabilities: Dict[str, Any] = {} + try: + loaded = json.loads(str(row.metadata_json or "{}")) + if isinstance(loaded, dict): + metadata = loaded + except Exception: + metadata = {} + try: + loaded_capabilities = json.loads(str(row.capabilities_json or "{}")) + if isinstance(loaded_capabilities, dict): + capabilities = loaded_capabilities + except Exception: + capabilities = {} + resources: Dict[str, Any] = {} + try: + loaded_resources = json.loads(str(row.resources_json or "{}")) + if isinstance(loaded_resources, dict): + resources = loaded_resources + except Exception: + resources = {} + metadata.setdefault("transport_kind", str(row.transport_kind or "").strip().lower() or "edge") + metadata.setdefault("runtime_kind", str(row.runtime_kind or "").strip().lower() or "docker") + metadata.setdefault("core_adapter", str(row.core_adapter or "").strip().lower() or "nanobot") + return ManagedNode( + node_id=str(row.node_id or "").strip().lower(), + display_name=str(row.display_name or row.node_id or "").strip(), + base_url=str(row.base_url or "").strip(), + enabled=bool(row.enabled), + auth_token=str(row.auth_token or "").strip(), + metadata=metadata, + capabilities=capabilities, + resources=resources, + last_seen_at=(row.last_seen_at.isoformat() + "Z") if row.last_seen_at else None, + ) + + @staticmethod + def _parse_datetime(value: Optional[str]) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw: + return None + normalized = raw[:-1] if raw.endswith("Z") else raw + try: + return datetime.fromisoformat(normalized) + except Exception: + return None diff --git a/backend/services/platform_service.py b/backend/services/platform_service.py index 1b28806..51f0e9f 100644 --- a/backend/services/platform_service.py +++ b/backend/services/platform_service.py @@ -1,14 +1,16 @@ import json +import logging import math import os import re import uuid from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple from sqlalchemy import delete as sql_delete, func from sqlmodel import Session, select +from clients.edge.errors import log_edge_failure from core.database import engine from core.settings import ( BOTS_WORKSPACE_ROOT, @@ -46,6 +48,7 @@ ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days" ACTIVITY_EVENT_PRUNE_INTERVAL = timedelta(minutes=10) OPERATIONAL_ACTIVITY_EVENT_TYPES = { "bot_created", + "bot_deployed", "bot_started", "bot_stopped", "bot_warning", @@ -151,6 +154,7 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { } _last_activity_event_prune_at: Optional[datetime] = None +logger = logging.getLogger(__name__) def _utcnow() -> datetime: @@ -272,6 +276,20 @@ def _calc_dir_size_bytes(path: str) -> int: return total +def _workspace_usage_bytes(runtime: Dict[str, Any], bot_id: str) -> int: + usage = dict(runtime.get("usage") or {}) + value = usage.get("workspace_used_bytes") + if value in {None, 0, "0", ""}: + value = usage.get("container_rw_bytes") + try: + normalized = int(value or 0) + except Exception: + normalized = 0 + if normalized > 0: + return normalized + return _calc_dir_size_bytes(_bot_workspace_root(bot_id)) + + def _read_bot_resources(bot_id: str) -> Dict[str, Any]: path = os.path.join(_bot_data_root(bot_id), "resources.json") raw: Dict[str, Any] = {} @@ -959,7 +977,10 @@ def list_activity_events( return items -def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, Any]: +def build_platform_overview( + session: Session, + read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None, +) -> Dict[str, Any]: deleted = prune_expired_activity_events(session, force=False) if deleted > 0: session.commit() @@ -978,15 +999,37 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, live_cpu_percent_total = 0.0 live_memory_used_total = 0 live_memory_limit_total = 0 + dirty = False bot_rows: List[Dict[str, Any]] = [] for bot in bots: enabled = bool(getattr(bot, "enabled", True)) - runtime_status = docker_manager.get_bot_status(bot.id) if docker_manager else str(bot.docker_status or "STOPPED") resources = _read_bot_resources(bot.id) - runtime = docker_manager.get_bot_resource_snapshot(bot.id) if docker_manager else {"usage": {}, "limits": {}, "docker_status": runtime_status} - workspace_root = _bot_workspace_root(bot.id) - workspace_used = _calc_dir_size_bytes(workspace_root) + runtime_status = str(bot.docker_status or "STOPPED").upper() + runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status} + if callable(read_runtime): + try: + runtime_status, runtime = read_runtime(bot) + except Exception as exc: + log_edge_failure( + logger, + key=f"platform-overview-runtime:{bot.id}", + exc=exc, + message=f"Failed to read platform runtime snapshot for bot_id={bot.id}", + ) + runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper() + runtime["docker_status"] = runtime_status + if str(bot.docker_status or "").upper() != runtime_status: + bot.docker_status = runtime_status + session.add(bot) + dirty = True + if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: + next_state = "IDLE" + if str(bot.current_state or "") != next_state: + bot.current_state = next_state + session.add(bot) + dirty = True + workspace_used = _workspace_usage_bytes(runtime, bot.id) workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 configured_cpu_total += float(resources["cpu_cores"] or 0) @@ -1022,6 +1065,9 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, } ) + if dirty: + session.commit() + usage = list_usage(session, limit=20) events = list_activity_events(session, limit=20) @@ -1064,3 +1110,90 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, "usage": usage, "events": events, } + + +def build_node_resource_overview( + session: Session, + *, + node_id: str, + read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None, +) -> Dict[str, Any]: + normalized_node_id = str(node_id or "").strip().lower() + bots = session.exec(select(BotInstance).where(BotInstance.node_id == normalized_node_id)).all() + + running = 0 + stopped = 0 + disabled = 0 + configured_cpu_total = 0.0 + configured_memory_total = 0 + configured_storage_total = 0 + workspace_used_total = 0 + workspace_limit_total = 0 + live_cpu_percent_total = 0.0 + live_memory_used_total = 0 + live_memory_limit_total = 0 + dirty = False + + for bot in bots: + enabled = bool(getattr(bot, "enabled", True)) + resources = _read_bot_resources(bot.id) + runtime_status = str(bot.docker_status or "STOPPED").upper() + runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status} + if callable(read_runtime): + try: + runtime_status, runtime = read_runtime(bot) + except Exception as exc: + log_edge_failure( + logger, + key=f"platform-node-runtime:{normalized_node_id}:{bot.id}", + exc=exc, + message=f"Failed to read node runtime snapshot for bot_id={bot.id}", + ) + runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper() + runtime["docker_status"] = runtime_status + if str(bot.docker_status or "").upper() != runtime_status: + bot.docker_status = runtime_status + session.add(bot) + dirty = True + + workspace_used = _workspace_usage_bytes(runtime, bot.id) + workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 + + configured_cpu_total += float(resources["cpu_cores"] or 0) + configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 + configured_storage_total += workspace_limit + workspace_used_total += workspace_used + workspace_limit_total += workspace_limit + live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0) + live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0) + live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0) + + if not enabled: + disabled += 1 + elif runtime_status == "RUNNING": + running += 1 + else: + stopped += 1 + + if dirty: + session.commit() + + return { + "node_id": normalized_node_id, + "bots": { + "total": len(bots), + "running": running, + "stopped": stopped, + "disabled": disabled, + }, + "resources": { + "configured_cpu_cores": round(configured_cpu_total, 2), + "configured_memory_bytes": configured_memory_total, + "configured_storage_bytes": configured_storage_total, + "live_cpu_percent": round(live_cpu_percent_total, 2), + "live_memory_used_bytes": live_memory_used_total, + "live_memory_limit_bytes": live_memory_limit_total, + "workspace_used_bytes": workspace_used_total, + "workspace_limit_bytes": workspace_limit_total, + }, + } diff --git a/backend/services/runtime_service.py b/backend/services/runtime_service.py new file mode 100644 index 0000000..28934f2 --- /dev/null +++ b/backend/services/runtime_service.py @@ -0,0 +1,133 @@ +from datetime import datetime +from typing import Any, Callable, Dict + +from sqlmodel import Session, select + +from models.bot import BotInstance, BotMessage +from providers.runtime.base import RuntimeProvider +from services.bot_command_service import BotCommandService + + +class RuntimeService: + def __init__( + self, + *, + command_service: BotCommandService, + resolve_runtime_provider: Callable[[Any, BotInstance], RuntimeProvider], + clear_bot_sessions: Callable[[str], int], + clear_dashboard_direct_session_file: Callable[[str], Dict[str, Any]], + invalidate_bot_detail_cache: Callable[[str], None], + invalidate_bot_messages_cache: Callable[[str], None], + record_activity_event: Callable[..., None], + ) -> None: + self._command_service = command_service + self._resolve_runtime_provider = resolve_runtime_provider + self._clear_bot_sessions = clear_bot_sessions + self._clear_dashboard_direct_session_file = clear_dashboard_direct_session_file + self._invalidate_bot_detail_cache = invalidate_bot_detail_cache + self._invalidate_bot_messages_cache = invalidate_bot_messages_cache + self._record_activity_event = record_activity_event + + async def start_bot(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]: + result = await self._resolve_runtime_provider(app_state, bot).start_bot(session=session, bot=bot) + self._invalidate_bot_detail_cache(str(bot.id or "")) + return result + + def stop_bot(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]: + result = self._resolve_runtime_provider(app_state, bot).stop_bot(session=session, bot=bot) + self._invalidate_bot_detail_cache(str(bot.id or "")) + return result + + def send_command( + self, + *, + app_state: Any, + session: Session, + bot_id: str, + bot: BotInstance, + payload: Any, + ) -> Dict[str, Any]: + return self._command_service.execute( + session=session, + bot_id=bot_id, + bot=bot, + payload=payload, + runtime_provider=self._resolve_runtime_provider(app_state, bot), + app_state=app_state, + ) + + def get_logs(self, *, app_state: Any, bot: BotInstance, tail: int = 300) -> Dict[str, Any]: + return { + "bot_id": bot.id, + "logs": self._resolve_runtime_provider(app_state, bot).get_recent_logs(bot_id=bot.id, tail=tail), + } + + def ensure_monitor(self, *, app_state: Any, bot: BotInstance) -> bool: + return bool(self._resolve_runtime_provider(app_state, bot).ensure_monitor(bot_id=bot.id)) + + def sync_edge_monitor_packets(self, *, app_state: Any, bot: BotInstance, request_id: str) -> None: + runtime_provider = self._resolve_runtime_provider(app_state, bot) + self._command_service.sync_edge_monitor_packets( + runtime_provider=runtime_provider, + bot_id=str(bot.id or "").strip(), + request_id=str(request_id or "").strip(), + app_state=app_state, + ) + + def clear_messages(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]: + bot_id = str(bot.id or "").strip() + rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all() + deleted = 0 + for row in rows: + session.delete(row) + deleted += 1 + + cleared_sessions = self._clear_bot_sessions(bot_id) + self._reset_running_runtime_session(app_state=app_state, bot=bot) + bot.last_action = "" + bot.current_state = "IDLE" + bot.updated_at = datetime.utcnow() + session.add(bot) + self._record_activity_event( + session, + bot_id, + "history_cleared", + channel="system", + detail=f"Cleared {deleted} stored messages", + metadata={"deleted_messages": deleted, "cleared_sessions": cleared_sessions}, + ) + session.commit() + self._invalidate_bot_detail_cache(bot_id) + self._invalidate_bot_messages_cache(bot_id) + return {"bot_id": bot_id, "deleted": deleted, "cleared_sessions": cleared_sessions} + + def clear_dashboard_direct_session(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]: + bot_id = str(bot.id or "").strip() + result = self._clear_dashboard_direct_session_file(bot_id) + self._reset_running_runtime_session(app_state=app_state, bot=bot) + bot.updated_at = datetime.utcnow() + session.add(bot) + self._record_activity_event( + session, + bot_id, + "dashboard_session_cleared", + channel="dashboard", + detail="Cleared dashboard_direct session file", + metadata={"session_file": result["path"], "previously_existed": result["existed"]}, + ) + session.commit() + self._invalidate_bot_detail_cache(bot_id) + return {"bot_id": bot_id, "cleared": True, "session_file": result["path"], "previously_existed": result["existed"]} + + def _reset_running_runtime_session(self, *, app_state: Any, bot: BotInstance) -> None: + if not self._is_runtime_running(bot): + return + try: + self._resolve_runtime_provider(app_state, bot).deliver_command(bot_id=str(bot.id), command="/new") + except Exception: + pass + + @staticmethod + def _is_runtime_running(bot: BotInstance) -> bool: + runtime_status = str(getattr(bot, "runtime_status", None) or getattr(bot, "docker_status", None) or "").upper() + return runtime_status == "RUNNING" diff --git a/backend/services/workspace_service.py b/backend/services/workspace_service.py new file mode 100644 index 0000000..e6f17de --- /dev/null +++ b/backend/services/workspace_service.py @@ -0,0 +1,68 @@ +from typing import Any, Dict, List, Optional + +from fastapi import Request, UploadFile + +from models.bot import BotInstance +from providers.selector import get_workspace_provider + + +class WorkspaceService: + def list_tree( + self, + *, + app_state: Any, + bot: BotInstance, + path: Optional[str] = None, + recursive: bool = False, + ) -> Dict[str, Any]: + return get_workspace_provider(app_state, bot).list_tree(bot_id=bot.id, path=path, recursive=recursive) + + def read_file( + self, + *, + app_state: Any, + bot: BotInstance, + path: str, + max_bytes: int = 200000, + ) -> Dict[str, Any]: + return get_workspace_provider(app_state, bot).read_file(bot_id=bot.id, path=path, max_bytes=max_bytes) + + def write_markdown( + self, + *, + app_state: Any, + bot: BotInstance, + path: str, + content: str, + ) -> Dict[str, Any]: + return get_workspace_provider(app_state, bot).write_markdown(bot_id=bot.id, path=path, content=content) + + async def upload_files( + self, + *, + app_state: Any, + bot: BotInstance, + files: List[UploadFile], + path: Optional[str] = None, + ) -> Dict[str, Any]: + return await get_workspace_provider(app_state, bot).upload_files(bot_id=bot.id, files=files, path=path) + + def serve_file( + self, + *, + app_state: Any, + bot: BotInstance, + path: str, + download: bool, + request: Request, + public: bool = False, + redirect_html_to_raw: bool = False, + ): + return get_workspace_provider(app_state, bot).serve_file( + bot_id=bot.id, + path=path, + download=download, + request=request, + public=public, + redirect_html_to_raw=redirect_html_to_raw, + ) diff --git a/dashboard-edge/Dockerfile b/dashboard-edge/Dockerfile new file mode 100644 index 0000000..ee1ddaf --- /dev/null +++ b/dashboard-edge/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app + +EXPOSE 8010 + +CMD ["python", "main.py"] diff --git a/dashboard-edge/README.md b/dashboard-edge/README.md new file mode 100644 index 0000000..d718bb6 --- /dev/null +++ b/dashboard-edge/README.md @@ -0,0 +1,59 @@ +# dashboard-edge + +`dashboard-edge` is the execution-plane service for Dashboard Nanobot. + +It is designed to run on every managed node and bridge Dashboard control requests to local Docker or native Bot runtimes. + +## Local development + +```bash +cd dashboard-edge +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +If your configured pip mirror is unavailable, install with the official index: + +```bash +pip install -r requirements.txt -i https://pypi.org/simple +``` + +Default server: + +- Host: `0.0.0.0` +- Port: `8010` +- Dev reload: enabled by default in `./scripts/dev-edge.sh` + +Native local development: + +```bash +cd /Users/jiliu/WorkSpace/dashboard-nanobot +bash scripts/dev-edge-native.sh +``` + +By default, the native launcher will auto-detect: + +- `engines/nanobot-v0.1.4-post5/.venv/bin/python -m nanobot.cli.commands gateway` + +when that virtualenv exists. You can still override it with `EDGE_NATIVE_COMMAND`. + +Environment variables: + +- `EDGE_HOST` +- `EDGE_PORT` +- `EDGE_RELOAD` +- `EDGE_AUTH_TOKEN` +- `EDGE_NODE_ID` +- `EDGE_NODE_NAME` +- `EDGE_BOTS_WORKSPACE_ROOT` +- `EDGE_BASE_IMAGE` +- `EDGE_RUNTIME_KIND` +- `EDGE_NATIVE_COMMAND` +- `EDGE_NATIVE_DASHBOARD_URL` +- `EDGE_NATIVE_DASHBOARD_HOST` +- `EDGE_NATIVE_DASHBOARD_PORT` +- `EDGE_NATIVE_WORKDIR` +- `EDGE_UPLOAD_MAX_MB` +- `EDGE_ALLOWED_ATTACHMENT_EXTENSIONS` diff --git a/dashboard-edge/app/__init__.py b/dashboard-edge/app/__init__.py new file mode 100644 index 0000000..60125b1 --- /dev/null +++ b/dashboard-edge/app/__init__.py @@ -0,0 +1 @@ +# dashboard-edge application package. diff --git a/dashboard-edge/app/api/__init__.py b/dashboard-edge/app/api/__init__.py new file mode 100644 index 0000000..1955ac2 --- /dev/null +++ b/dashboard-edge/app/api/__init__.py @@ -0,0 +1 @@ +# API package for dashboard-edge. diff --git a/dashboard-edge/app/api/router.py b/dashboard-edge/app/api/router.py new file mode 100644 index 0000000..76fdd44 --- /dev/null +++ b/dashboard-edge/app/api/router.py @@ -0,0 +1,220 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile + +from app.dependencies.auth import require_edge_auth +from app.schemas.edge import ( + EdgeCommandRequest, + EdgeLogsResponse, + EdgeNativePreflightRequest, + EdgeNativePreflightResponse, + EdgeNodeHeartbeatResponse, + EdgeMonitorPacketsResponse, + EdgeMarkdownWriteRequest, + EdgeMonitorEnsureResponse, + EdgeNodeResourcesResponse, + EdgeNodeSelfResponse, + EdgeStateResponse, + EdgeStateWriteRequest, + EdgeStatusResponse, + EdgeWorkspaceSyncRequest, +) +from app.schemas.runtime import EdgeStartBotRequest +from app.services import provision_service as provision_service_module +from app.services import state_store_service as state_store_service_module +from app.services.runtime_service import edge_runtime_service +from app.services import workspace_service as workspace_service_module + +router = APIRouter(dependencies=[Depends(require_edge_auth)]) + + +@router.get("/api/edge/node/self", response_model=EdgeNodeSelfResponse) +def get_edge_node_self(): + return edge_runtime_service.get_node_identity() + + +@router.get("/api/edge/node/resources", response_model=EdgeNodeResourcesResponse) +def get_edge_node_resources(): + return edge_runtime_service.get_node_resource_summary() + + +@router.post("/api/edge/node/heartbeat", response_model=EdgeNodeHeartbeatResponse) +def heartbeat_edge_node(): + return edge_runtime_service.heartbeat() + + +@router.post("/api/edge/runtime/native/preflight", response_model=EdgeNativePreflightResponse) +def native_preflight(payload: EdgeNativePreflightRequest): + return edge_runtime_service.native_preflight( + native_command=str(payload.native_command or "").strip() or None, + native_workdir=str(payload.native_workdir or "").strip() or None, + ) + + +@router.post("/api/edge/bots/{bot_id}/start", response_model=EdgeStatusResponse) +async def start_bot(bot_id: str, payload: EdgeStartBotRequest): + return await edge_runtime_service.start_bot(bot_id=bot_id, payload=payload) + + +@router.post("/api/edge/bots/{bot_id}/stop", response_model=EdgeStatusResponse) +def stop_bot(bot_id: str): + return edge_runtime_service.stop_bot(bot_id=bot_id) + + +@router.post("/api/edge/bots/{bot_id}/command", response_model=EdgeStatusResponse) +def send_command(bot_id: str, payload: EdgeCommandRequest): + return edge_runtime_service.send_command(bot_id=bot_id, payload=payload) + + +@router.post("/api/edge/bots/{bot_id}/monitor/ensure", response_model=EdgeMonitorEnsureResponse) +def ensure_monitor(bot_id: str): + return edge_runtime_service.ensure_monitor(bot_id=bot_id) + + +@router.get("/api/edge/bots/{bot_id}/monitor/packets", response_model=EdgeMonitorPacketsResponse) +def get_monitor_packets(bot_id: str, after_seq: int = 0, limit: int = 200): + return edge_runtime_service.get_monitor_packets(bot_id=bot_id, after_seq=after_seq, limit=limit) + + +@router.get("/api/edge/bots/{bot_id}/logs", response_model=EdgeLogsResponse) +def get_logs(bot_id: str, tail: int = Query(300, ge=1, le=2000)): + return edge_runtime_service.get_recent_logs(bot_id=bot_id, tail=tail) + + +@router.get("/api/edge/bots/{bot_id}/runtime/status", response_model=EdgeStatusResponse) +def get_runtime_status(bot_id: str): + return edge_runtime_service.get_runtime_status(bot_id=bot_id) + + +@router.get("/api/edge/bots/{bot_id}/resources") +def get_resource_snapshot(bot_id: str): + return edge_runtime_service.get_resource_snapshot(bot_id=bot_id) + + +@router.post("/api/edge/bots/{bot_id}/workspace/sync", response_model=EdgeStatusResponse) +def sync_workspace(bot_id: str, payload: EdgeWorkspaceSyncRequest): + return provision_service_module.edge_provision_service.sync_bot_workspace(bot_id=bot_id, payload=payload) + + +@router.get("/api/edge/bots/{bot_id}/state/{state_key}", response_model=EdgeStateResponse) +def read_bot_state(bot_id: str, state_key: str, workspace_root: str | None = None): + return state_store_service_module.edge_state_store_service.read_state( + bot_id=bot_id, + state_key=state_key, + workspace_root=workspace_root, + ) + + +@router.put("/api/edge/bots/{bot_id}/state/{state_key}", response_model=EdgeStateResponse) +def write_bot_state(bot_id: str, state_key: str, payload: EdgeStateWriteRequest): + return state_store_service_module.edge_state_store_service.write_state( + bot_id=bot_id, + state_key=state_key, + data=dict(payload.data or {}), + workspace_root=str(payload.workspace_root or "").strip() or None, + ) + + +@router.get("/api/edge/bots/{bot_id}/workspace/tree") +def list_workspace_tree( + bot_id: str, + path: str | None = None, + recursive: bool = False, + workspace_root: str | None = None, +): + return workspace_service_module.edge_workspace_service.list_tree( + bot_id=bot_id, + path=path, + recursive=recursive, + workspace_root=workspace_root, + ) + + +@router.get("/api/edge/bots/{bot_id}/workspace/file") +def read_workspace_file( + bot_id: str, + path: str = Query(...), + max_bytes: int = Query(200000, ge=4096, le=1000000), + workspace_root: str | None = None, +): + return workspace_service_module.edge_workspace_service.read_file( + bot_id=bot_id, + path=path, + max_bytes=max_bytes, + workspace_root=workspace_root, + ) + + +@router.put("/api/edge/bots/{bot_id}/workspace/file/markdown") +def write_workspace_markdown( + bot_id: str, + path: str = Query(...), + payload: EdgeMarkdownWriteRequest = None, + workspace_root: str | None = None, +): + if payload is None: + raise HTTPException(status_code=400, detail="markdown payload is required") + return workspace_service_module.edge_workspace_service.write_markdown( + bot_id=bot_id, + path=path, + content=payload.content, + workspace_root=workspace_root, + ) + + +@router.post("/api/edge/bots/{bot_id}/workspace/upload") +async def upload_workspace_files( + bot_id: str, + files: List[UploadFile] = File(...), + path: Optional[str] = None, + workspace_root: str | None = None, +): + return await workspace_service_module.edge_workspace_service.upload_files( + bot_id=bot_id, + files=files, + path=path, + workspace_root=workspace_root, + ) + + +@router.get("/api/edge/bots/{bot_id}/workspace/download") +def download_workspace_file( + bot_id: str, + path: str = Query(...), + download: bool = False, + request: Request = None, + workspace_root: str | None = None, +): + return workspace_service_module.edge_workspace_service.serve_file( + bot_id=bot_id, + path=path, + download=download, + request=request, + workspace_root=workspace_root, + ) + + +@router.get("/api/edge/bots/{bot_id}/workspace/raw/{path:path}") +def raw_workspace_file( + bot_id: str, + path: str, + download: bool = False, + request: Request = None, + workspace_root: str | None = None, +): + return workspace_service_module.edge_workspace_service.serve_file( + bot_id=bot_id, + path=path, + download=download, + request=request, + workspace_root=workspace_root, + ) + + +@router.post("/api/edge/bots/{bot_id}/workspace/purge", response_model=EdgeStatusResponse) +def purge_workspace(bot_id: str, workspace_root: str | None = None): + result = workspace_service_module.edge_workspace_service.purge_bot_workspace( + bot_id=bot_id, + workspace_root=workspace_root, + ) + return EdgeStatusResponse(status="deleted" if bool(result.get("deleted")) else "not_found") diff --git a/dashboard-edge/app/core/__init__.py b/dashboard-edge/app/core/__init__.py new file mode 100644 index 0000000..1a1e7d7 --- /dev/null +++ b/dashboard-edge/app/core/__init__.py @@ -0,0 +1 @@ +# Core package for dashboard-edge. diff --git a/dashboard-edge/app/core/settings.py b/dashboard-edge/app/core/settings.py new file mode 100644 index 0000000..bd24edf --- /dev/null +++ b/dashboard-edge/app/core/settings.py @@ -0,0 +1,86 @@ +import os +import re +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +EDGE_ROOT = Path(__file__).resolve().parents[2] +PROJECT_ROOT = EDGE_ROOT.parent + +EDGE_HOST = str(os.getenv("EDGE_HOST", "0.0.0.0") or "0.0.0.0").strip() or "0.0.0.0" + +try: + EDGE_PORT = int(os.getenv("EDGE_PORT", "8010")) +except Exception: + EDGE_PORT = 8010 +EDGE_PORT = max(1, min(EDGE_PORT, 65535)) + +EDGE_RELOAD = str(os.getenv("EDGE_RELOAD", "true")).strip().lower() in {"1", "true", "yes", "on"} +EDGE_AUTH_TOKEN = str(os.getenv("EDGE_AUTH_TOKEN", "") or "").strip() +EDGE_NODE_ID = str(os.getenv("EDGE_NODE_ID", "local") or "local").strip().lower() or "local" +EDGE_NODE_NAME = str(os.getenv("EDGE_NODE_NAME", "Local Node") or "Local Node").strip() or "Local Node" +EDGE_BASE_IMAGE = str(os.getenv("EDGE_BASE_IMAGE", "nanobot-base:v0.1.4") or "nanobot-base:v0.1.4").strip() +EDGE_LOG_LEVEL = str(os.getenv("EDGE_LOG_LEVEL", "warning") or "warning").strip().lower() or "warning" +EDGE_ACCESS_LOG = str(os.getenv("EDGE_ACCESS_LOG", "false")).strip().lower() in {"1", "true", "yes", "on"} + + +def _default_native_command() -> str: + configured = str(os.getenv("EDGE_NATIVE_COMMAND", "") or "").strip() + if configured: + return configured + native_python = PROJECT_ROOT / "engines" / "nanobot-v0.1.4-post5" / ".venv" / "bin" / "python" + if native_python.is_file() and os.access(native_python, os.X_OK): + return f"{native_python} -m nanobot.cli.commands gateway" + return "nanobot gateway" + + +EDGE_NATIVE_COMMAND = _default_native_command() +EDGE_NATIVE_DASHBOARD_URL = str( + os.getenv("EDGE_NATIVE_DASHBOARD_URL", "http://127.0.0.1:9000/chat") or "http://127.0.0.1:9000/chat" +).strip() or "http://127.0.0.1:9000/chat" +EDGE_NATIVE_WORKDIR = str(os.getenv("EDGE_NATIVE_WORKDIR", "") or "").strip() +EDGE_BOTS_WORKSPACE_ROOT = str( + Path(os.getenv("EDGE_BOTS_WORKSPACE_ROOT", str(PROJECT_ROOT / "workspace" / "bots"))).expanduser().resolve() +) + + +def _env_int(name: str, default: int, min_value: int, max_value: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + value = int(str(raw).strip()) + except Exception: + value = default + return max(min_value, min(max_value, value)) + + +def _normalize_extension(raw: str) -> str: + text = str(raw or "").strip().lower() + if not text: + return "" + if text.startswith("*."): + text = text[1:] + if not text.startswith("."): + text = f".{text}" + if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text): + return "" + return text + + +def _env_extensions(name: str) -> tuple[str, ...]: + raw = os.getenv(name) + if raw is None: + return () + rows: list[str] = [] + for item in re.split(r"[,;\s]+", str(raw)): + ext = _normalize_extension(item) + if ext and ext not in rows: + rows.append(ext) + return tuple(rows) + + +EDGE_UPLOAD_MAX_MB = _env_int("EDGE_UPLOAD_MAX_MB", 100, 1, 2048) +EDGE_ALLOWED_ATTACHMENT_EXTENSIONS = _env_extensions("EDGE_ALLOWED_ATTACHMENT_EXTENSIONS") diff --git a/dashboard-edge/app/dependencies/__init__.py b/dashboard-edge/app/dependencies/__init__.py new file mode 100644 index 0000000..3bcd20a --- /dev/null +++ b/dashboard-edge/app/dependencies/__init__.py @@ -0,0 +1 @@ +# Dependency package for dashboard-edge. diff --git a/dashboard-edge/app/dependencies/auth.py b/dashboard-edge/app/dependencies/auth.py new file mode 100644 index 0000000..89e062a --- /dev/null +++ b/dashboard-edge/app/dependencies/auth.py @@ -0,0 +1,14 @@ +from fastapi import Header, HTTPException + +from app.core.settings import EDGE_AUTH_TOKEN + +EDGE_AUTH_HEADER = "x-dashboard-edge-token" + + +def require_edge_auth(x_dashboard_edge_token: str | None = Header(default=None)) -> None: + configured = str(EDGE_AUTH_TOKEN or "").strip() + if not configured: + return + supplied = str(x_dashboard_edge_token or "").strip() + if supplied != configured: + raise HTTPException(status_code=401, detail="Invalid dashboard-edge token") diff --git a/dashboard-edge/app/main.py b/dashboard-edge/app/main.py new file mode 100644 index 0000000..e80c767 --- /dev/null +++ b/dashboard-edge/app/main.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI + +from app.api.router import router as edge_router +from app.core.settings import EDGE_BOTS_WORKSPACE_ROOT, EDGE_NODE_ID, EDGE_NODE_NAME +from app.services.provision_service import EdgeProvisionService +from app.services.runtime_service import edge_runtime_service +from app.services.state_store_service import EdgeStateStoreService +from app.services.workspace_service import EdgeWorkspaceService + +app = FastAPI(title="Dashboard Edge API") +app.include_router(edge_router) +app.state.edge_runtime_service = edge_runtime_service + +from app.services import provision_service as provision_service_module +from app.services import state_store_service as state_store_service_module +from app.services import workspace_service as workspace_service_module + +provision_service_module.edge_provision_service = EdgeProvisionService(host_data_root=EDGE_BOTS_WORKSPACE_ROOT) +state_store_service_module.edge_state_store_service = EdgeStateStoreService(host_data_root=EDGE_BOTS_WORKSPACE_ROOT) +workspace_service_module.edge_workspace_service = EdgeWorkspaceService(host_data_root=EDGE_BOTS_WORKSPACE_ROOT) + + +@app.get("/api/edge/health") +def healthcheck(): + return { + "status": "ok", + "service": "dashboard-edge", + "node_id": EDGE_NODE_ID, + "node_name": EDGE_NODE_NAME, + } diff --git a/dashboard-edge/app/runtime/__init__.py b/dashboard-edge/app/runtime/__init__.py new file mode 100644 index 0000000..32f59d5 --- /dev/null +++ b/dashboard-edge/app/runtime/__init__.py @@ -0,0 +1 @@ +# Runtime package for dashboard-edge. diff --git a/dashboard-edge/app/runtime/base.py b/dashboard-edge/app/runtime/base.py new file mode 100644 index 0000000..3685a81 --- /dev/null +++ b/dashboard-edge/app/runtime/base.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, List, Optional + + +class EdgeRuntimeBackend(ABC): + runtime_kind: str = "docker" + + @abstractmethod + def capabilities(self) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def has_image(self, tag: str) -> bool: + raise NotImplementedError + + @abstractmethod + def start_bot( + self, + bot_id: str, + image_tag: Optional[str] = None, + env_vars: Optional[Dict[str, str]] = None, + workspace_root: Optional[str] = None, + native_command: Optional[str] = None, + native_workdir: Optional[str] = None, + cpu_cores: Optional[float] = None, + memory_mb: Optional[int] = None, + storage_gb: Optional[int] = None, + on_state_change: Optional[Callable[[str, dict], None]] = None, + ) -> bool: + raise NotImplementedError + + @abstractmethod + def ensure_monitor(self, bot_id: str, on_state_change: Callable[[str, dict], None]) -> bool: + raise NotImplementedError + + @abstractmethod + def stop_bot(self, bot_id: str) -> bool: + raise NotImplementedError + + @abstractmethod + def get_bot_status(self, bot_id: str) -> str: + raise NotImplementedError + + @abstractmethod + def get_bot_resource_snapshot(self, bot_id: str) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]: + raise NotImplementedError + + @abstractmethod + def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: + raise NotImplementedError + + @abstractmethod + def get_last_delivery_error(self, bot_id: str) -> str: + raise NotImplementedError + + @abstractmethod + def parse_monitor_packet(self, line: str) -> Optional[Dict[str, Any]]: + raise NotImplementedError diff --git a/dashboard-edge/app/runtime/docker_manager.py b/dashboard-edge/app/runtime/docker_manager.py new file mode 100644 index 0000000..c4fb398 --- /dev/null +++ b/dashboard-edge/app/runtime/docker_manager.py @@ -0,0 +1,710 @@ +import base64 +import codecs +import json +import os +import re +import threading +import time +from typing import Any, Callable, Dict, List, Optional, Tuple + +import docker +import httpx + +from app.runtime.base import EdgeRuntimeBackend + + +class EdgeDockerManager(EdgeRuntimeBackend): + runtime_kind = "docker" + + def __init__(self, host_data_root: str, base_image: str = "nanobot-base:v0.1.4") -> None: + try: + self.client = docker.from_env(timeout=6) + self.client.version() + print("✅ Edge Docker engine connected") + except Exception as exc: + self.client = None + print(f"⚠️ Edge Docker engine unavailable: {exc}") + self.host_data_root = host_data_root + self.base_image = base_image + self.active_monitors: Dict[str, threading.Thread] = {} + self._last_delivery_error: Dict[str, str] = {} + + def capabilities(self) -> Dict[str, Any]: + return { + "protocol": {"version": "1"}, + "runtime": {"docker": bool(self.client is not None), "native": False}, + "workspace": { + "tree": True, + "read_file": True, + "write_markdown": True, + "upload_files": True, + "serve_file": True, + }, + "monitor": {"logs": True, "ensure": True}, + } + + @staticmethod + def _normalize_resource_limits( + cpu_cores: Optional[float], + memory_mb: Optional[int], + storage_gb: Optional[int], + ) -> Tuple[float, int, int]: + try: + cpu = float(cpu_cores) if cpu_cores is not None else 1.0 + except Exception: + cpu = 1.0 + try: + memory = int(memory_mb) if memory_mb is not None else 1024 + except Exception: + memory = 1024 + try: + storage = int(storage_gb) if storage_gb is not None else 10 + except Exception: + storage = 10 + if cpu < 0: + cpu = 1.0 + if memory < 0: + memory = 1024 + if storage < 0: + storage = 10 + cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) + memory = 0 if memory == 0 else min(65536, max(256, memory)) + storage = 0 if storage == 0 else min(1024, max(1, storage)) + return cpu, memory, storage + + def has_image(self, tag: str) -> bool: + if not self.client: + return False + try: + self.client.images.get(tag) + return True + except Exception: + return False + + def start_bot( + self, + bot_id: str, + image_tag: Optional[str] = None, + env_vars: Optional[Dict[str, str]] = None, + workspace_root: Optional[str] = None, + native_command: Optional[str] = None, + native_workdir: Optional[str] = None, + cpu_cores: Optional[float] = None, + memory_mb: Optional[int] = None, + storage_gb: Optional[int] = None, + on_state_change: Optional[Callable[[str, dict], None]] = None, + ) -> bool: + if not self.client: + return False + image = image_tag or self.base_image + if not self.has_image(image): + return False + + state_nanobot_dir = self._state_nanobot_dir(bot_id=bot_id, workspace_root=workspace_root) + workspace_dir = self._workspace_dir(bot_id=bot_id, workspace_root=workspace_root) + default_workspace_dir = os.path.join(state_nanobot_dir, "workspace") + container_name = f"worker_{bot_id}" + os.makedirs(state_nanobot_dir, exist_ok=True) + os.makedirs(workspace_dir, exist_ok=True) + cpu, memory, storage = self._normalize_resource_limits(cpu_cores, memory_mb, storage_gb) + volumes = { + state_nanobot_dir: {"bind": "/root/.nanobot", "mode": "rw"}, + } + if os.path.abspath(workspace_dir) != os.path.abspath(default_workspace_dir): + volumes[workspace_dir] = {"bind": "/root/.nanobot/workspace", "mode": "rw"} + base_kwargs = { + "image": image, + "name": container_name, + "detach": True, + "stdin_open": True, + "tty": True, + "environment": env_vars or {}, + "volumes": volumes, + "network_mode": "bridge", + } + if memory > 0: + base_kwargs["mem_limit"] = f"{memory}m" + if cpu > 0: + base_kwargs["nano_cpus"] = int(cpu * 1_000_000_000) + + try: + try: + container = self.client.containers.get(container_name) + container.reload() + if container.status == "running": + if on_state_change: + self.ensure_monitor(bot_id, on_state_change) + return True + container.remove(force=True) + except docker.errors.NotFound: + pass + + if storage > 0: + try: + container = self.client.containers.run( + storage_opt={"size": f"{storage}G"}, + **base_kwargs, + ) + except Exception: + container = self.client.containers.run(**base_kwargs) + else: + container = self.client.containers.run(**base_kwargs) + + if on_state_change: + monitor_thread = threading.Thread( + target=self._monitor_container_logs, + args=(bot_id, container, on_state_change), + daemon=True, + ) + monitor_thread.start() + self.active_monitors[bot_id] = monitor_thread + return True + except Exception: + return False + + def ensure_monitor(self, bot_id: str, on_state_change: Callable[[str, dict], None]) -> bool: + if not self.client: + return False + existing = self.active_monitors.get(bot_id) + if existing and existing.is_alive(): + return True + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.reload() + if container.status != "running": + return False + monitor_thread = threading.Thread( + target=self._monitor_container_logs, + args=(bot_id, container, on_state_change), + daemon=True, + ) + monitor_thread.start() + self.active_monitors[bot_id] = monitor_thread + return True + except Exception: + return False + + def stop_bot(self, bot_id: str) -> bool: + if not self.client: + return False + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.stop(timeout=5) + container.remove() + self.active_monitors.pop(bot_id, None) + return True + except docker.errors.NotFound: + self.active_monitors.pop(bot_id, None) + return False + except Exception: + return False + + def get_bot_status(self, bot_id: str) -> str: + if not self.client: + return "STOPPED" + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.reload() + raw = str(container.status or "").strip().lower() + if raw in {"running", "restarting"}: + return "RUNNING" + return "STOPPED" + except Exception: + return "STOPPED" + + @staticmethod + def _parse_size_to_bytes(raw: Any) -> Optional[int]: + if raw is None: + return None + text = str(raw).strip() + if not text: + return None + try: + return int(float(text)) + except Exception: + pass + match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)\s*([kmgtp]?)(i?b)?", text.lower()) + if not match: + return None + number = float(match.group(1)) + unit = (match.group(2) or "").lower() + scale = { + "": 1, + "k": 1024, + "m": 1024 ** 2, + "g": 1024 ** 3, + "t": 1024 ** 4, + "p": 1024 ** 5, + }.get(unit, 1) + return int(number * scale) + + @staticmethod + def _calc_cpu_percent(stats: Dict[str, Any]) -> float: + try: + cpu_stats = stats.get("cpu_stats") or {} + precpu_stats = stats.get("precpu_stats") or {} + cpu_total = float((cpu_stats.get("cpu_usage") or {}).get("total_usage") or 0) + prev_cpu_total = float((precpu_stats.get("cpu_usage") or {}).get("total_usage") or 0) + cpu_delta = cpu_total - prev_cpu_total + system_total = float(cpu_stats.get("system_cpu_usage") or 0) + prev_system_total = float(precpu_stats.get("system_cpu_usage") or 0) + system_delta = system_total - prev_system_total + online_cpus = int( + cpu_stats.get("online_cpus") + or len((cpu_stats.get("cpu_usage") or {}).get("percpu_usage") or []) + or 1 + ) + if cpu_delta <= 0 or system_delta <= 0: + return 0.0 + return max(0.0, (cpu_delta / system_delta) * online_cpus * 100.0) + except Exception: + return 0.0 + + def get_bot_resource_snapshot(self, bot_id: str) -> Dict[str, Any]: + snapshot: Dict[str, Any] = { + "docker_status": "STOPPED", + "limits": { + "cpu_cores": None, + "memory_bytes": None, + "storage_bytes": None, + "nano_cpus": 0, + "storage_opt_raw": "", + }, + "usage": { + "cpu_percent": 0.0, + "memory_bytes": 0, + "memory_limit_bytes": 0, + "memory_percent": 0.0, + "network_rx_bytes": 0, + "network_tx_bytes": 0, + "blk_read_bytes": 0, + "blk_write_bytes": 0, + "pids": 0, + "container_rw_bytes": 0, + }, + } + if not self.client: + return snapshot + + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.reload() + status_raw = str(container.status or "").strip().lower() + snapshot["docker_status"] = "RUNNING" if status_raw in {"running", "restarting"} else "STOPPED" + + inspect: Dict[str, Any] + try: + inspect = self.client.api.inspect_container(container.id, size=True) + except TypeError: + inspect = self.client.api.inspect_container(container.id) + except Exception as e: + if "unexpected keyword argument 'size'" in str(e): + inspect = self.client.api.inspect_container(container.id) + else: + raise + host_cfg = inspect.get("HostConfig") or {} + nano_cpus = int(host_cfg.get("NanoCpus") or 0) + cpu_quota = int(host_cfg.get("CpuQuota") or 0) + cpu_period = int(host_cfg.get("CpuPeriod") or 0) + memory_bytes = int(host_cfg.get("Memory") or 0) + storage_opt = host_cfg.get("StorageOpt") or {} + storage_raw = storage_opt.get("size") + storage_bytes = self._parse_size_to_bytes(storage_raw) + + if nano_cpus > 0: + cpu_cores = nano_cpus / 1_000_000_000 + elif cpu_quota > 0 and cpu_period > 0: + cpu_cores = cpu_quota / cpu_period + else: + cpu_cores = None + + snapshot["limits"] = { + "cpu_cores": cpu_cores, + "memory_bytes": memory_bytes if memory_bytes > 0 else None, + "storage_bytes": storage_bytes, + "nano_cpus": nano_cpus, + "storage_opt_raw": str(storage_raw or ""), + } + snapshot["usage"]["container_rw_bytes"] = int(inspect.get("SizeRw") or 0) + + if snapshot["docker_status"] == "RUNNING": + stats = container.stats(stream=False) or {} + memory_stats = stats.get("memory_stats") or {} + memory_usage = int(memory_stats.get("usage") or 0) + memory_limit = int(memory_stats.get("limit") or 0) + if memory_usage > 0: + cache = int((memory_stats.get("stats") or {}).get("inactive_file") or 0) + memory_usage = max(0, memory_usage - cache) + networks = stats.get("networks") or {} + rx_total = 0 + tx_total = 0 + for _, row in networks.items(): + if isinstance(row, dict): + rx_total += int(row.get("rx_bytes") or 0) + tx_total += int(row.get("tx_bytes") or 0) + blk_stats = stats.get("blkio_stats") or {} + io_rows = blk_stats.get("io_service_bytes_recursive") or [] + blk_read = 0 + blk_write = 0 + for row in io_rows: + if not isinstance(row, dict): + continue + op = str(row.get("op") or "").upper() + value = int(row.get("value") or 0) + if op == "READ": + blk_read += value + elif op == "WRITE": + blk_write += value + pids_current = int((stats.get("pids_stats") or {}).get("current") or 0) + cpu_percent = self._calc_cpu_percent(stats) + memory_percent = 0.0 + if memory_limit > 0: + memory_percent = (memory_usage / memory_limit) * 100.0 + if snapshot["usage"]["container_rw_bytes"] <= 0: + storage_stats = stats.get("storage_stats") or {} + rw_size = int(storage_stats.get("size_rw") or storage_stats.get("rw_size") or 0) + snapshot["usage"]["container_rw_bytes"] = max(0, rw_size) + + snapshot["usage"].update( + { + "cpu_percent": cpu_percent, + "memory_bytes": memory_usage, + "memory_limit_bytes": memory_limit, + "memory_percent": max(0.0, memory_percent), + "network_rx_bytes": rx_total, + "network_tx_bytes": tx_total, + "blk_read_bytes": blk_read, + "blk_write_bytes": blk_write, + "pids": pids_current, + } + ) + except docker.errors.NotFound: + return snapshot + except Exception: + return snapshot + return snapshot + + def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: + if not self.client: + self._last_delivery_error[bot_id] = "Docker client is not available" + return False + media_paths = [str(v).strip().replace("\\", "/") for v in (media or []) if str(v).strip()] + self._last_delivery_error.pop(bot_id, None) + for attempt in range(3): + if self._send_command_via_exec(bot_id, command, media_paths): + self._last_delivery_error.pop(bot_id, None) + return True + time.sleep(0.25 * (attempt + 1)) + if self._send_command_via_host_http(bot_id, command, media_paths): + self._last_delivery_error.pop(bot_id, None) + return True + if bot_id not in self._last_delivery_error: + self._last_delivery_error[bot_id] = "Unknown delivery failure" + return False + + def get_last_delivery_error(self, bot_id: str) -> str: + return str(self._last_delivery_error.get(bot_id, "") or "").strip() + + def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]: + if not self.client: + return [] + try: + container = self.client.containers.get(f"worker_{bot_id}") + raw = container.logs(tail=max(1, int(tail))) + text = raw.decode("utf-8", errors="ignore") + return [line for line in text.splitlines() if line.strip()] + except Exception: + return [] + + def parse_monitor_packet(self, line: str) -> Optional[Dict[str, Any]]: + return self._parse_log_line(str(line or "").strip()) + + def _workspace_dir(self, *, bot_id: str, workspace_root: Optional[str]) -> str: + return os.path.abspath(os.path.join(self._state_nanobot_dir(bot_id=bot_id, workspace_root=workspace_root), "workspace")) + + def _state_nanobot_dir(self, *, bot_id: str, workspace_root: Optional[str]) -> str: + configured_root = str(workspace_root or "").strip() + if not configured_root: + return os.path.abspath(os.path.join(self.host_data_root, bot_id, ".nanobot")) + normalized_root = os.path.abspath(os.path.expanduser(configured_root)) + return os.path.abspath(os.path.join(normalized_root, bot_id, ".nanobot")) + + def _monitor_container_logs(self, bot_id: str, container: Any, callback: Callable[[str, dict], None]) -> None: + try: + buffer = "" + decoder = codecs.getincrementaldecoder("utf-8")("replace") + since_ts = int(time.time()) + for chunk in container.logs(stream=True, follow=True, since=since_ts): + text = decoder.decode(chunk) if isinstance(chunk, bytes) else str(chunk) + if not text: + continue + buffer += text + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + normalized = line.strip("\r").strip() + if normalized: + state_packet = self._parse_log_line(normalized) + if state_packet: + callback(bot_id, state_packet) + callback(bot_id, {"type": "RAW_LOG", "text": normalized}) + rest = decoder.decode(b"", final=True) + if rest: + buffer += rest + tail = buffer.strip() + if tail: + state_packet = self._parse_log_line(tail) + if state_packet: + callback(bot_id, state_packet) + callback(bot_id, {"type": "RAW_LOG", "text": tail}) + except Exception: + return + + def _parse_monitor_packet_json(self, line: str) -> Optional[Dict[str, Any]]: + if "__DASHBOARD_DATA_START__" not in line or "__DASHBOARD_DATA_END__" not in line: + return None + try: + raw_json = line.split("__DASHBOARD_DATA_START__", 1)[1].split("__DASHBOARD_DATA_END__", 1)[0].strip() + data = json.loads(raw_json) + event_type = str(data.get("type", "")).upper() + content = str(data.get("content") or data.get("text") or "").strip() + media = [str(v).strip().replace("\\", "/") for v in (data.get("media") or []) if str(v).strip()] + is_progress = bool(data.get("is_progress", False)) + is_tool = bool(data.get("is_tool", False)) + usage = data.get("usage") if isinstance(data.get("usage"), dict) else None + request_id = str(data.get("request_id") or "").strip() or None + provider = str(data.get("provider") or "").strip() or None + model = str(data.get("model") or "").strip() or None + + if event_type == "AGENT_STATE": + payload = data.get("payload") or {} + state = str(payload.get("state") or data.get("state") or ("TOOL_CALL" if is_tool else "THINKING")) + action_msg = str(payload.get("action_msg") or payload.get("msg") or content) + return { + "type": "AGENT_STATE", + "channel": "dashboard", + "payload": {"state": state, "action_msg": action_msg}, + "request_id": request_id, + } + + if event_type == "ASSISTANT_MESSAGE": + if content or media: + return { + "type": "ASSISTANT_MESSAGE", + "channel": "dashboard", + "text": content, + "media": media, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } + return None + + if event_type == "BUS_EVENT" or is_progress: + return { + "type": "BUS_EVENT", + "channel": "dashboard", + "content": content, + "media": media, + "is_progress": is_progress, + "is_tool": is_tool, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } + + if content or media: + return { + "type": "ASSISTANT_MESSAGE", + "channel": "dashboard", + "text": content, + "media": media, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } + except Exception: + return None + return None + + def _parse_log_line(self, line: str) -> Optional[Dict[str, Any]]: + if "__DASHBOARD_DATA_START__" in line: + packet = self._parse_monitor_packet_json(line) + if packet: + return packet + + process_match = re.search(r"Processing message from ([\w\-]+):[^:]+:\s*(.+)$", line) + if process_match: + channel = process_match.group(1).strip().lower() + action_msg = process_match.group(2).strip() + return { + "type": "AGENT_STATE", + "channel": channel, + "payload": { + "state": "THINKING", + "action_msg": action_msg[:4000], + }, + } + + response_match = re.search(r"Response to ([\w\-]+):[^:]+:\s*(.+)$", line) + if response_match: + channel = response_match.group(1).strip().lower() + action_msg = response_match.group(2).strip() + return { + "type": "AGENT_STATE", + "channel": channel, + "payload": { + "state": "SUCCESS", + "action_msg": action_msg[:4000], + }, + } + + lower = line.lower() + tool_call_match = re.search(r"tool call:\s*(.+)$", line, re.IGNORECASE) + if tool_call_match: + return { + "type": "AGENT_STATE", + "payload": { + "state": "TOOL_CALL", + "action_msg": tool_call_match.group(1).strip()[:4000], + }, + } + + if "error" in lower or "traceback" in lower: + return { + "type": "AGENT_STATE", + "payload": {"state": "ERROR", "action_msg": "执行异常,请检查日志"}, + } + return None + + def _send_command_via_exec(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.reload() + if container.status != "running": + self._last_delivery_error[bot_id] = f"Container status is {container.status}" + return False + dashboard_port = self._resolve_dashboard_port(container=container, bot_id=bot_id) + dashboard_url = f"http://127.0.0.1:{dashboard_port}/chat" + payload_json = json.dumps({"message": command, "media": media or []}, ensure_ascii=False) + result = container.exec_run( + [ + "curl", + "-sS", + "--fail", + "--max-time", + "6", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + payload_json, + dashboard_url, + ] + ) + output = result.output.decode("utf-8", errors="ignore") if isinstance(result.output, (bytes, bytearray)) else str(result.output) + if result.exit_code != 0: + payload_b64 = base64.b64encode(payload_json.encode("utf-8")).decode("ascii") + py_script = ( + "import base64,json,os,urllib.request\n" + "payload=json.loads(base64.b64decode(os.environ['DASHBOARD_PAYLOAD_B64']).decode('utf-8'))\n" + "req=urllib.request.Request(os.environ.get('DASHBOARD_CHAT_URL', 'http://127.0.0.1:9000/chat')," + "data=json.dumps(payload,ensure_ascii=False).encode('utf-8')," + "headers={'Content-Type':'application/json'})\n" + "with urllib.request.urlopen(req, timeout=8) as resp:\n" + " print(resp.read().decode('utf-8','ignore'))\n" + ) + for py_bin in ["python3", "python"]: + py_result = container.exec_run( + [py_bin, "-c", py_script], + environment={ + "DASHBOARD_PAYLOAD_B64": payload_b64, + "DASHBOARD_CHAT_URL": dashboard_url, + }, + ) + py_output = py_result.output.decode("utf-8", errors="ignore") if isinstance(py_result.output, (bytes, bytearray)) else str(py_result.output) + if py_result.exit_code == 0: + return True + self._last_delivery_error[bot_id] = f"exec fallback failed: {py_output[:300]}" + self._last_delivery_error[bot_id] = f"exec curl failed: {output[:300]}" + return False + return True + except Exception as exc: + self._last_delivery_error[bot_id] = f"exec curl exception: {exc}" + return False + + def _send_command_via_host_http(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.reload() + ip_address = self._resolve_container_ip(container) + if not ip_address: + self._last_delivery_error[bot_id] = "host HTTP failed: container has no reachable IP address" + return False + dashboard_port = self._resolve_dashboard_port(container=container, bot_id=bot_id) + target_url = f"http://{ip_address}:{dashboard_port}/chat" + with httpx.Client(timeout=4.0) as client: + resp = client.post(target_url, json={"message": command, "media": media or []}) + if resp.status_code == 200: + return True + self._last_delivery_error[bot_id] = f"host HTTP failed: {resp.status_code} - {resp.text[:300]}" + return False + except Exception as exc: + self._last_delivery_error[bot_id] = f"host HTTP exception: {exc}" + return False + + def _resolve_dashboard_port(self, *, container: Any, bot_id: str) -> int: + # Dashboard channel port may be per-bot dynamic; read from mounted config.json when available. + default_port = 9000 + config_path = self._resolve_mounted_config_path(container=container, bot_id=bot_id) + if not config_path or not os.path.isfile(config_path): + return default_port + try: + with open(config_path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + return default_port + channels = payload.get("channels") + if not isinstance(channels, dict): + return default_port + dashboard = channels.get("dashboard") + if not isinstance(dashboard, dict): + return default_port + raw_port = int(dashboard.get("port") or default_port) + if 1 <= raw_port <= 65535: + return raw_port + except Exception: + return default_port + return default_port + + def _resolve_mounted_config_path(self, *, container: Any, bot_id: str) -> str: + mounts = list((container.attrs or {}).get("Mounts") or []) + for row in mounts: + if not isinstance(row, dict): + continue + destination = str(row.get("Destination") or "").strip() + if destination != "/root/.nanobot": + continue + source = str(row.get("Source") or "").strip() + if source: + return os.path.join(source, "config.json") + return os.path.join(self.host_data_root, bot_id, ".nanobot", "config.json") + + @staticmethod + def _resolve_container_ip(container: Any) -> str: + attrs = dict(getattr(container, "attrs", {}) or {}) + network = dict(attrs.get("NetworkSettings") or {}) + primary = str(network.get("IPAddress") or "").strip() + if primary: + return primary + networks = dict(network.get("Networks") or {}) + for _, row in networks.items(): + if not isinstance(row, dict): + continue + ip = str(row.get("IPAddress") or "").strip() + if ip: + return ip + return "" diff --git a/dashboard-edge/app/runtime/factory.py b/dashboard-edge/app/runtime/factory.py new file mode 100644 index 0000000..9f94fb1 --- /dev/null +++ b/dashboard-edge/app/runtime/factory.py @@ -0,0 +1,31 @@ +import os +from typing import Dict + +from app.core.settings import EDGE_BOTS_WORKSPACE_ROOT, EDGE_BASE_IMAGE +from app.runtime.docker_manager import EdgeDockerManager +from app.runtime.native_manager import EdgeNativeRuntimeBackend + + +def edge_runtime_mode() -> str: + runtime_kind = str(os.getenv("EDGE_RUNTIME_KIND", "all") or "all").strip().lower() + if runtime_kind in {"docker", "native"}: + return runtime_kind + return "all" + + +def build_edge_runtime_backends() -> Dict[str, object]: + mode = edge_runtime_mode() + backends: Dict[str, object] = {} + if mode in {"all", "docker"}: + backends["docker"] = EdgeDockerManager(host_data_root=EDGE_BOTS_WORKSPACE_ROOT, base_image=EDGE_BASE_IMAGE) + if mode in {"all", "native"}: + backends["native"] = EdgeNativeRuntimeBackend() + return backends + + +def preferred_edge_runtime_kind(backends: Dict[str, object]) -> str: + if "docker" in backends: + return "docker" + if "native" in backends: + return "native" + return "docker" diff --git a/dashboard-edge/app/runtime/native_manager.py b/dashboard-edge/app/runtime/native_manager.py new file mode 100644 index 0000000..d04fc7a --- /dev/null +++ b/dashboard-edge/app/runtime/native_manager.py @@ -0,0 +1,776 @@ +import codecs +import csv +import hashlib +import json +import signal +import socket +import os +import re +import shlex +import shutil +import subprocess +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional + +import httpx +import psutil + +from app.core.settings import ( + EDGE_BOTS_WORKSPACE_ROOT, + EDGE_NATIVE_COMMAND, + EDGE_NATIVE_DASHBOARD_URL, + EDGE_NATIVE_WORKDIR, +) +from app.runtime.base import EdgeRuntimeBackend + + +@dataclass +class _NativeProcessRecord: + process: subprocess.Popen[str] + command: List[str] + cwd: str + log_path: str + log_handle: Any + dashboard_url: str + dashboard_host: str + dashboard_port: int + cpu_cores: Optional[float] + memory_mb: Optional[int] + storage_gb: Optional[int] + stop_event: threading.Event = field(default_factory=threading.Event) + stdout_thread: Optional[threading.Thread] = None + last_error: str = "" + + +class EdgeNativeRuntimeBackend(EdgeRuntimeBackend): + runtime_kind = "native" + + def __init__(self) -> None: + self._command = shlex.split(EDGE_NATIVE_COMMAND) + self._native_available = bool(self._command and shutil.which(self._command[0])) + self._last_errors: Dict[str, str] = {} + self._records: Dict[str, _NativeProcessRecord] = {} + self._lock = threading.RLock() + + def capabilities(self) -> Dict[str, Any]: + available = bool(self._native_available) + return { + "protocol": {"version": "1"}, + "runtime": {"docker": False, "native": available}, + "workspace": { + "tree": True, + "read_file": True, + "write_markdown": True, + "upload_files": True, + "serve_file": True, + }, + "monitor": {"logs": available, "ensure": available}, + "process": {"command": list(self._command), "available": available}, + } + + def has_image(self, tag: str) -> bool: + return False + + def start_bot( + self, + bot_id: str, + image_tag: Optional[str] = None, + env_vars: Optional[Dict[str, str]] = None, + workspace_root: Optional[str] = None, + native_command: Optional[str] = None, + native_workdir: Optional[str] = None, + cpu_cores: Optional[float] = None, + memory_mb: Optional[int] = None, + storage_gb: Optional[int] = None, + on_state_change: Optional[Callable[[str, dict], None]] = None, + ) -> bool: + bot_id = str(bot_id or "").strip() + if not bot_id: + return False + effective_env = dict(env_vars or {}) + launch_command = self._resolve_launch_command(native_command=native_command, env_vars=effective_env) + if not self._is_launch_command_available(launch_command): + self._set_last_error(bot_id, f"native command not available: {self._render_command(launch_command) or 'nanobot gateway'}") + return False + + with self._lock: + existing = self._records.get(bot_id) + if existing and existing.process.poll() is None: + if on_state_change: + self.ensure_monitor(bot_id, on_state_change) + return True + if existing: + self._cleanup_record(bot_id, existing) + + state_root = self._bot_root(bot_id) + workspace_dir = self._workspace_dir(bot_id=bot_id, workspace_root=workspace_root) + config_path = self._config_path(bot_id, workspace_root=workspace_root) + runtime_dir = os.path.join(os.path.dirname(config_path), "runtime") + os.makedirs(runtime_dir, exist_ok=True) + os.makedirs(workspace_dir, exist_ok=True) + log_path = os.path.join(runtime_dir, "native.log") + cwd = self._resolve_workdir(state_root, native_workdir=native_workdir, env_vars=effective_env) + dashboard_host, dashboard_port, dashboard_url = self._resolve_dashboard_endpoint(bot_id, effective_env) + env = os.environ.copy() + env.update({str(k): str(v) for k, v in effective_env.items() if str(k).strip()}) + env.setdefault("PYTHONUNBUFFERED", "1") + env.setdefault("EDGE_RUNTIME_KIND", "native") + env.setdefault("EDGE_NODE_MODE", "native") + env.setdefault("NANOBOT_BOT_ID", bot_id) + env.setdefault("DASHBOARD_HOST", dashboard_host) + env.setdefault("DASHBOARD_PORT", str(dashboard_port)) + env.setdefault("DASHBOARD_URL", dashboard_url) + env.setdefault("NANOBOT_CONFIG", config_path) + env.setdefault("NANOBOT_WORKSPACE", workspace_dir) + + if not os.path.isfile(config_path): + self._set_last_error(bot_id, f"native config not found: {config_path}") + return False + + self._terminate_orphan_processes(bot_id=bot_id, config_path=config_path) + + log_handle = open(log_path, "a", encoding="utf-8") + command = self._build_launch_command(base_command=launch_command, config_path=config_path, workspace_dir=workspace_dir) + log_handle.write( + f"[{self._now()}] native bootstrap command={shlex.join(command)} cwd={cwd} config={config_path} workspace={workspace_dir} dashboard={dashboard_url}\n" + ) + log_handle.flush() + try: + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + ) + except FileNotFoundError as exc: + log_handle.write(f"[{self._now()}] native bootstrap failed: {exc}\n") + log_handle.flush() + log_handle.close() + self._set_last_error(bot_id, f"native command not found: {exc}") + return False + except Exception as exc: + log_handle.write(f"[{self._now()}] native bootstrap failed: {exc}\n") + log_handle.flush() + log_handle.close() + self._set_last_error(bot_id, f"native start failed: {exc}") + return False + + record = _NativeProcessRecord( + process=process, + command=command, + cwd=cwd, + log_path=log_path, + log_handle=log_handle, + dashboard_url=dashboard_url, + dashboard_host=dashboard_host, + dashboard_port=dashboard_port, + cpu_cores=cpu_cores, + memory_mb=memory_mb, + storage_gb=storage_gb, + ) + self._records[bot_id] = record + record.stdout_thread = threading.Thread( + target=self._drain_stdout, + args=(bot_id, record, on_state_change), + daemon=True, + ) + record.stdout_thread.start() + if not self._wait_for_dashboard_ready(record): + self._set_last_error(bot_id, f"native dashboard did not become ready: {dashboard_url}") + try: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + except Exception: + pass + self._cleanup_record(bot_id, record) + self._records.pop(bot_id, None) + return False + self._set_last_error(bot_id, "") + return True + + def ensure_monitor(self, bot_id: str, on_state_change: Callable[[str, dict], None]) -> bool: + record = self._records.get(bot_id) + if record is None or record.process.poll() is not None: + return False + thread = record.stdout_thread + if thread is not None and thread.is_alive(): + return True + record.stdout_thread = threading.Thread( + target=self._drain_stdout, + args=(bot_id, record, on_state_change), + daemon=True, + ) + record.stdout_thread.start() + return True + + def stop_bot(self, bot_id: str) -> bool: + bot_id = str(bot_id or "").strip() + with self._lock: + record = self._records.pop(bot_id, None) + stopped = False + if record is not None: + try: + if record.process.poll() is None: + record.stop_event.set() + record.process.terminate() + try: + record.process.wait(timeout=8) + except Exception: + record.process.kill() + record.process.wait(timeout=5) + self._cleanup_record(bot_id, record) + stopped = True + except Exception as exc: + self._set_last_error(bot_id, f"native stop failed: {exc}") + self._cleanup_record(bot_id, record) + return False + orphan_stopped = self._terminate_orphan_processes(bot_id=bot_id, config_path=self._config_path(bot_id)) + return bool(stopped or orphan_stopped) + + def get_bot_status(self, bot_id: str) -> str: + normalized_bot_id = str(bot_id or "").strip() + record = self._records.get(normalized_bot_id) + if record is None: + return "RUNNING" if self._has_orphan_process(normalized_bot_id) else "STOPPED" + try: + return "RUNNING" if record.process.poll() is None else "STOPPED" + except Exception: + return "STOPPED" + + def get_bot_resource_snapshot(self, bot_id: str) -> Dict[str, Any]: + bot_id = str(bot_id or "").strip() + record = self._records.get(bot_id) + snapshot: Dict[str, Any] = { + "docker_status": self.get_bot_status(bot_id), + "limits": { + "cpu_cores": self._normalize_cpu_limit(record.cpu_cores if record else None), + "memory_bytes": self._normalize_memory_limit(record.memory_mb if record else None), + "storage_bytes": self._normalize_storage_limit(record.storage_gb if record else None), + "nano_cpus": 0, + "storage_opt_raw": "", + }, + "usage": { + "cpu_percent": 0.0, + "memory_bytes": 0, + "memory_limit_bytes": 0, + "memory_percent": 0.0, + "network_rx_bytes": 0, + "network_tx_bytes": 0, + "blk_read_bytes": 0, + "blk_write_bytes": 0, + "pids": 0, + "container_rw_bytes": 0, + }, + } + if record is None or record.process.poll() is not None: + return snapshot + try: + proc = psutil.Process(record.process.pid) + cpu_percent = float(proc.cpu_percent(interval=None) or 0.0) + memory_info = proc.memory_info() + memory_bytes = int(getattr(memory_info, "rss", 0) or 0) + memory_limit = int(psutil.virtual_memory().total or 0) + memory_percent = float(proc.memory_percent() or 0.0) + children = proc.children(recursive=True) + workspace_used = self._calc_workspace_used_bytes(bot_id) + snapshot["usage"].update( + { + "cpu_percent": round(cpu_percent, 2), + "memory_bytes": memory_bytes, + "memory_limit_bytes": memory_limit, + "memory_percent": round(memory_percent, 2), + "network_rx_bytes": 0, + "network_tx_bytes": 0, + "blk_read_bytes": 0, + "blk_write_bytes": 0, + "pids": 1 + len(children), + "container_rw_bytes": workspace_used, + } + ) + except Exception: + workspace_used = self._calc_workspace_used_bytes(bot_id) + snapshot["usage"]["container_rw_bytes"] = workspace_used + return snapshot + + def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]: + log_path = self._log_path(str(bot_id or "").strip()) + if not os.path.isfile(log_path): + return [] + try: + with open(log_path, "r", encoding="utf-8", errors="ignore") as fh: + rows = [line.rstrip("\n") for line in fh.readlines() if line.strip()] + if tail > 0: + return rows[-int(tail) :] + return rows + except Exception: + return [] + + def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: + bot_id = str(bot_id or "").strip() + record = self._records.get(bot_id) + if record is None or record.process.poll() is not None: + self._set_last_error(bot_id, "native process is not running") + return False + try: + payload = {"message": command, "media": list(media or [])} + with httpx.Client(timeout=5.0, trust_env=False) as client: + resp = client.post(record.dashboard_url, json=payload) + if resp.status_code == 200: + self._set_last_error(bot_id, "") + return True + self._set_last_error(bot_id, f"native dashboard returned {resp.status_code}: {resp.text[:300]}") + return False + except Exception as exc: + self._set_last_error(bot_id, f"native dashboard request failed: {exc}") + return False + + def get_last_delivery_error(self, bot_id: str) -> str: + bot_id = str(bot_id or "").strip() + record = self._records.get(bot_id) + if record is None: + return str(self._last_errors.get(bot_id) or "").strip() + return str(record.last_error or self._last_errors.get(bot_id) or "").strip() + + def parse_monitor_packet(self, line: str) -> Optional[Dict[str, Any]]: + return self._parse_log_line(str(line or "").strip()) + + def _drain_stdout( + self, + bot_id: str, + record: _NativeProcessRecord, + callback: Optional[Callable[[str, dict], None]] = None, + ) -> None: + stream = record.process.stdout + if stream is None: + return + try: + for raw_line in iter(stream.readline, ""): + if record.stop_event.is_set(): + break + line = str(raw_line or "").rstrip("\r\n") + if not line: + continue + try: + record.log_handle.write(f"{line}\n") + record.log_handle.flush() + except Exception: + pass + if callback: + parsed = self._parse_log_line(line) + if parsed: + callback(bot_id, parsed) + callback(bot_id, {"type": "RAW_LOG", "text": line}) + finally: + try: + stream.close() + except Exception: + pass + try: + record.log_handle.flush() + except Exception: + pass + + def _cleanup_record(self, bot_id: str, record: _NativeProcessRecord) -> None: + try: + record.stop_event.set() + except Exception: + pass + try: + if record.log_handle and not record.log_handle.closed: + record.log_handle.flush() + record.log_handle.close() + except Exception: + pass + + def _set_last_error(self, bot_id: str, message: str) -> None: + normalized_bot_id = str(bot_id or "").strip() + self._last_errors[normalized_bot_id] = str(message or "").strip() + record = self._records.get(normalized_bot_id) + if record is None: + return + record.last_error = self._last_errors[normalized_bot_id] + + def _resolve_workdir( + self, + bot_root: str, + *, + native_workdir: Optional[str] = None, + env_vars: Optional[Dict[str, str]] = None, + ) -> str: + configured = str(native_workdir or (env_vars or {}).get("EDGE_NATIVE_WORKDIR") or EDGE_NATIVE_WORKDIR or "").strip() + if configured: + return os.path.abspath(configured) + return os.path.abspath(bot_root) + + def _resolve_dashboard_endpoint(self, bot_id: str, env_vars: Dict[str, str]) -> tuple[str, int, str]: + host = str(env_vars.get("DASHBOARD_HOST") or os.getenv("EDGE_NATIVE_DASHBOARD_HOST") or "127.0.0.1").strip() or "127.0.0.1" + raw_port = str(env_vars.get("DASHBOARD_PORT") or os.getenv("EDGE_NATIVE_DASHBOARD_PORT") or "").strip() + try: + port = int(raw_port) if raw_port else self._default_dashboard_port(bot_id) + except Exception: + port = self._default_dashboard_port(bot_id) + port = max(1, min(port, 65535)) + url = str(env_vars.get("DASHBOARD_URL") or os.getenv("EDGE_NATIVE_DASHBOARD_URL") or f"http://{host}:{port}/chat").strip() + if not url: + url = f"http://{host}:{port}/chat" + return host, port, url + + def _build_launch_command(self, *, base_command: List[str], config_path: str, workspace_dir: str) -> List[str]: + command = list(base_command) + has_config_flag = any(part in {"--config", "-c"} for part in command) + has_workspace_flag = any(part in {"--workspace", "-w"} for part in command) + if not has_config_flag: + command.extend(["--config", config_path]) + if not has_workspace_flag: + command.extend(["--workspace", workspace_dir]) + return command + + def _resolve_launch_command(self, *, native_command: Optional[str], env_vars: Dict[str, str]) -> List[str]: + explicit = str(native_command or "").strip() + if explicit: + return self._parse_launcher_command(explicit) + configured = str(env_vars.get("EDGE_NATIVE_COMMAND") or "").strip() + if configured: + rows = self._parse_launcher_command(configured) + if rows: + return rows + return list(self._command) + + @staticmethod + def _parse_launcher_command(raw_command: str) -> List[str]: + text = str(raw_command or "").strip() + if not text: + return [] + if text.startswith("[") and text.endswith("]"): + try: + payload = json.loads(text) + if isinstance(payload, list): + rows = [str(item or "").strip() for item in payload if str(item or "").strip()] + if rows: + return rows + except Exception: + pass + if "," in text and any(mark in text for mark in ['"', "'"]): + try: + rows = [str(item or "").strip() for item in next(csv.reader([text], skipinitialspace=True)) if str(item or "").strip()] + if rows: + return rows + except Exception: + pass + try: + return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()] + except Exception: + return [] + + @staticmethod + def _is_launch_command_available(command: List[str]) -> bool: + if not command: + return False + return bool(shutil.which(command[0])) + + @staticmethod + def _render_command(command: List[str]) -> str: + return " ".join(str(part or "").strip() for part in command if str(part or "").strip()) + + def _log_path(self, bot_id: str) -> str: + config_path = self._config_path(bot_id) + return os.path.join(os.path.dirname(config_path), "runtime", "native.log") + + def _config_path(self, bot_id: str, workspace_root: Optional[str] = None) -> str: + configured_root = str(workspace_root or "").strip() + if configured_root: + external_config = os.path.abspath( + os.path.join( + os.path.abspath(os.path.expanduser(configured_root)), + bot_id, + ".nanobot", + "config.json", + ) + ) + if os.path.isfile(external_config): + return external_config + inferred_root = self._workspace_root_from_runtime_target(bot_id) + if inferred_root: + inferred_config = os.path.abspath(os.path.join(inferred_root, bot_id, ".nanobot", "config.json")) + if os.path.isfile(inferred_config): + return inferred_config + return os.path.join(self._bot_root(bot_id), ".nanobot", "config.json") + + def _bot_root(self, bot_id: str) -> str: + return os.path.abspath(os.path.join(EDGE_BOTS_WORKSPACE_ROOT, bot_id)) + + def _workspace_dir(self, *, bot_id: str, workspace_root: Optional[str] = None) -> str: + configured_root = str(workspace_root or "").strip() + if configured_root: + normalized_root = os.path.abspath(os.path.expanduser(configured_root)) + return os.path.abspath(os.path.join(normalized_root, bot_id, ".nanobot", "workspace")) + config_workspace = self._workspace_dir_from_config(bot_id) + if config_workspace: + return config_workspace + return os.path.abspath(os.path.join(self._bot_root(bot_id), ".nanobot", "workspace")) + + def _workspace_dir_from_config(self, bot_id: str) -> Optional[str]: + config_path = self._config_path(bot_id) + if not os.path.isfile(config_path): + return None + try: + with open(config_path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + return None + agents = payload.get("agents") if isinstance(payload.get("agents"), dict) else {} + defaults = agents.get("defaults") if isinstance(agents.get("defaults"), dict) else {} + workspace = str(defaults.get("workspace") or "").strip() + if not workspace: + return None + return os.path.abspath(os.path.expanduser(workspace)) + except Exception: + return None + + def _workspace_root_from_runtime_target(self, bot_id: str) -> str: + path = os.path.join(self._bot_root(bot_id), ".nanobot", "runtime-target.json") + if not os.path.isfile(path): + return "" + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + return "" + raw_root = str(payload.get("workspace_root") or "").strip() + if not raw_root: + return "" + return os.path.abspath(os.path.expanduser(raw_root)) + except Exception: + return "" + + def _has_orphan_process(self, bot_id: str) -> bool: + return bool(self._find_orphan_processes(bot_id=bot_id, config_path=self._config_path(bot_id))) + + def _find_orphan_processes(self, *, bot_id: str, config_path: str) -> List[psutil.Process]: + matches: List[psutil.Process] = [] + normalized_config_path = os.path.abspath(config_path) + for proc in psutil.process_iter(["pid", "cmdline"]): + try: + cmdline = [str(part or "") for part in (proc.info.get("cmdline") or [])] + if not cmdline: + continue + joined = " ".join(cmdline) + if "nanobot.cli.commands" not in joined or " gateway" not in joined: + continue + if normalized_config_path not in joined: + continue + matches.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception: + continue + return matches + + def _terminate_orphan_processes(self, *, bot_id: str, config_path: str) -> int: + stopped = 0 + for proc in self._find_orphan_processes(bot_id=bot_id, config_path=config_path): + try: + os.kill(int(proc.pid), signal.SIGTERM) + try: + proc.wait(timeout=5) + except psutil.TimeoutExpired: + os.kill(int(proc.pid), signal.SIGKILL) + proc.wait(timeout=3) + stopped += 1 + except (psutil.NoSuchProcess, ProcessLookupError): + continue + except Exception as exc: + self._set_last_error(bot_id, f"failed to cleanup orphan native process: {exc}") + return stopped + + @staticmethod + def _wait_for_dashboard_ready(record: _NativeProcessRecord, timeout_seconds: float = 8.0) -> bool: + deadline = time.monotonic() + max(1.0, float(timeout_seconds or 8.0)) + while time.monotonic() < deadline: + if record.process.poll() is not None: + return False + try: + with socket.create_connection((record.dashboard_host, record.dashboard_port), timeout=0.5): + return True + except OSError: + time.sleep(0.2) + continue + return False + + @staticmethod + def _default_dashboard_port(bot_id: str) -> int: + digest = hashlib.sha1(str(bot_id or "").strip().encode("utf-8")).hexdigest() + return 19000 + (int(digest[:6], 16) % 2000) + + @staticmethod + def _normalize_cpu_limit(value: Optional[float]) -> Optional[float]: + if value is None: + return None + try: + return round(float(value), 2) + except Exception: + return None + + @staticmethod + def _normalize_memory_limit(value: Optional[int]) -> Optional[int]: + if value is None: + return None + try: + return max(0, int(value)) * 1024 * 1024 + except Exception: + return None + + @staticmethod + def _normalize_storage_limit(value: Optional[int]) -> Optional[int]: + if value is None: + return None + try: + return max(0, int(value)) * 1024 * 1024 * 1024 + except Exception: + return None + + def _calc_workspace_used_bytes(self, bot_id: str) -> int: + total = 0 + root = self._workspace_dir(bot_id=bot_id) + for current_root, _, files in os.walk(root): + for filename in files: + path = os.path.join(current_root, filename) + try: + total += int(os.path.getsize(path)) + except Exception: + continue + return total + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + @staticmethod + def _parse_monitor_packet_json(line: str) -> Optional[Dict[str, Any]]: + if "__DASHBOARD_DATA_START__" not in line or "__DASHBOARD_DATA_END__" not in line: + return None + try: + raw_json = line.split("__DASHBOARD_DATA_START__", 1)[1].split("__DASHBOARD_DATA_END__", 1)[0].strip() + data = json.loads(raw_json) + event_type = str(data.get("type", "")).upper() + content = str(data.get("content") or data.get("text") or "").strip() + media = [str(v).strip().replace("\\", "/") for v in (data.get("media") or []) if str(v).strip()] + is_progress = bool(data.get("is_progress", False)) + is_tool = bool(data.get("is_tool", False)) + usage = data.get("usage") if isinstance(data.get("usage"), dict) else None + request_id = str(data.get("request_id") or "").strip() or None + provider = str(data.get("provider") or "").strip() or None + model = str(data.get("model") or "").strip() or None + + if event_type == "AGENT_STATE": + payload = data.get("payload") or {} + state = str(payload.get("state") or data.get("state") or ("TOOL_CALL" if is_tool else "THINKING")) + action_msg = str(payload.get("action_msg") or payload.get("msg") or content) + return { + "type": "AGENT_STATE", + "channel": "dashboard", + "payload": {"state": state, "action_msg": action_msg}, + "request_id": request_id, + } + + if event_type == "ASSISTANT_MESSAGE": + if content or media: + return { + "type": "ASSISTANT_MESSAGE", + "channel": "dashboard", + "text": content, + "media": media, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } + return None + + if event_type == "BUS_EVENT" or is_progress: + return { + "type": "BUS_EVENT", + "channel": "dashboard", + "content": content, + "media": media, + "is_progress": is_progress, + "is_tool": is_tool, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } + + if content or media: + return { + "type": "ASSISTANT_MESSAGE", + "channel": "dashboard", + "text": content, + "media": media, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } + except Exception: + return None + return None + + @classmethod + def _parse_log_line(cls, line: str) -> Optional[Dict[str, Any]]: + if "__DASHBOARD_DATA_START__" in line: + packet = cls._parse_monitor_packet_json(line) + if packet: + return packet + + process_match = re.search(r"Processing message from ([\w\-]+):[^:]+:\s*(.+)$", line) + if process_match: + channel = process_match.group(1).strip().lower() + action_msg = process_match.group(2).strip() + return { + "type": "AGENT_STATE", + "channel": channel, + "payload": { + "state": "THINKING", + "action_msg": action_msg[:4000], + }, + } + + response_match = re.search(r"Response to ([\w\-]+):[^:]+:\s*(.+)$", line) + if response_match: + channel = response_match.group(1).strip().lower() + action_msg = response_match.group(2).strip() + return { + "type": "AGENT_STATE", + "channel": channel, + "payload": { + "state": "SUCCESS", + "action_msg": action_msg[:4000], + }, + } + + tool_call_match = re.search(r"tool call:\s*(.+)$", line, re.IGNORECASE) + if tool_call_match: + return { + "type": "AGENT_STATE", + "payload": { + "state": "TOOL_CALL", + "action_msg": tool_call_match.group(1).strip()[:4000], + }, + } + + lower = line.lower() + if "error" in lower or "traceback" in lower: + return { + "type": "AGENT_STATE", + "payload": {"state": "ERROR", "action_msg": "执行异常,请检查日志"}, + } + return None diff --git a/dashboard-edge/app/schemas/__init__.py b/dashboard-edge/app/schemas/__init__.py new file mode 100644 index 0000000..9104f25 --- /dev/null +++ b/dashboard-edge/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schema package for dashboard-edge. diff --git a/dashboard-edge/app/schemas/edge.py b/dashboard-edge/app/schemas/edge.py new file mode 100644 index 0000000..c1c893f --- /dev/null +++ b/dashboard-edge/app/schemas/edge.py @@ -0,0 +1,116 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +NODE_PROTOCOL_VERSION = "1" + + +class EdgeNodeIdentityBase(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str + display_name: str + service: str = "dashboard-edge" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + + +class EdgeStatusResponse(BaseModel): + status: str + + +class EdgeStateWriteRequest(BaseModel): + data: Dict[str, Any] = Field(default_factory=dict) + workspace_root: Optional[str] = None + + +class EdgeStateResponse(BaseModel): + bot_id: str + state_key: str + data: Dict[str, Any] = Field(default_factory=dict) + + +class EdgeNativePreflightRequest(BaseModel): + native_command: Optional[str] = None + native_workdir: Optional[str] = None + + +class EdgeNativePreflightResponse(BaseModel): + ok: bool = False + command: List[str] = Field(default_factory=list) + workdir: str = "" + command_available: bool = False + workdir_exists: bool = False + detail: str = "" + + +class EdgeCommandRequest(BaseModel): + command: str + media: List[str] = Field(default_factory=list) + + +class EdgeLogsResponse(BaseModel): + bot_id: str + logs: List[str] = Field(default_factory=list) + + +class EdgeMonitorEnsureResponse(BaseModel): + ensured: bool = False + + +class EdgeMonitorPacket(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str = "" + bot_id: str = "" + seq: int = 0 + captured_at: str = "" + packet: Dict[str, Any] = Field(default_factory=dict) + + +class EdgeMonitorPacketsResponse(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str = "" + bot_id: str + latest_seq: int = 0 + packets: List[EdgeMonitorPacket] = Field(default_factory=list) + + +class EdgeWorkspaceSyncRequest(BaseModel): + channels_override: Optional[List[Dict[str, Any]]] = None + global_delivery_override: Optional[Dict[str, Any]] = None + runtime_overrides: Optional[Dict[str, Any]] = None + + +class EdgeMarkdownWriteRequest(BaseModel): + content: str = "" + + +class EdgeNodeSelfResponse(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str + display_name: str + service: str = "dashboard-edge" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + capabilities: Dict[str, Any] = Field(default_factory=dict) + resources: Dict[str, Any] = Field(default_factory=dict) + reported_at: str = "" + + +class EdgeNodeResourcesResponse(BaseModel): + protocol_version: str = NODE_PROTOCOL_VERSION + node_id: str + display_name: str = "" + service: str = "dashboard-edge" + transport_kind: str = "edge" + runtime_kind: str = "docker" + core_adapter: str = "nanobot" + resources: Dict[str, Any] = Field(default_factory=dict) + reported_at: str = "" + + +class EdgeNodeHeartbeatResponse(EdgeNodeIdentityBase): + capabilities: Dict[str, Any] = Field(default_factory=dict) + resources: Dict[str, Any] = Field(default_factory=dict) + reported_at: str = "" diff --git a/dashboard-edge/app/schemas/runtime.py b/dashboard-edge/app/schemas/runtime.py new file mode 100644 index 0000000..6a5dd01 --- /dev/null +++ b/dashboard-edge/app/schemas/runtime.py @@ -0,0 +1,15 @@ +from typing import Dict, Optional + +from pydantic import BaseModel, Field + + +class EdgeStartBotRequest(BaseModel): + image_tag: str + runtime_kind: str = "docker" + env_vars: Dict[str, str] = Field(default_factory=dict) + workspace_root: Optional[str] = None + native_command: Optional[str] = None + native_workdir: Optional[str] = None + cpu_cores: float = 1.0 + memory_mb: int = 1024 + storage_gb: int = 10 diff --git a/dashboard-edge/app/services/__init__.py b/dashboard-edge/app/services/__init__.py new file mode 100644 index 0000000..fbda8b0 --- /dev/null +++ b/dashboard-edge/app/services/__init__.py @@ -0,0 +1 @@ +# Service package for dashboard-edge. diff --git a/dashboard-edge/app/services/provision_service.py b/dashboard-edge/app/services/provision_service.py new file mode 100644 index 0000000..aea20e7 --- /dev/null +++ b/dashboard-edge/app/services/provision_service.py @@ -0,0 +1,279 @@ +import json +import os +import hashlib +from typing import Any, Dict, List, Optional + +DEFAULT_SOUL_MD = "# Soul\n" +DEFAULT_AGENTS_MD = "# Agent Instructions\n" +DEFAULT_USER_MD = "# User Preferences\n" +DEFAULT_TOOLS_MD = "# Tools\n" +DEFAULT_IDENTITY_MD = "# Identity\n" + + +class EdgeProvisionService: + def __init__(self, *, host_data_root: str) -> None: + self._host_data_root = host_data_root + + def sync_bot_workspace( + self, + *, + bot_id: str, + payload: Any, + ) -> Dict[str, Any]: + runtime = dict(getattr(payload, "runtime_overrides", None) or {}) + workspace_root_override = self._workspace_root_override(runtime) + workspace_bot_dir = self._bot_workspace_dir(bot_id, workspace_root_override) + state_nanobot_dir = os.path.join(workspace_bot_dir, ".nanobot") + workspace_dir = os.path.join(workspace_bot_dir, ".nanobot", "workspace") + memory_dir = os.path.join(workspace_dir, "memory") + skills_dir = os.path.join(workspace_dir, "skills") + for path in [state_nanobot_dir, workspace_dir, memory_dir, skills_dir]: + os.makedirs(path, exist_ok=True) + + channels_override = list(getattr(payload, "channels_override", None) or []) + global_delivery_override = dict(getattr(payload, "global_delivery_override", None) or {}) + + raw_provider_name = str(runtime.get("llm_provider") or "openrouter").strip().lower() + provider_name = { + "aliyun": "dashscope", + "qwen": "dashscope", + "aliyun-qwen": "dashscope", + "moonshot": "kimi", + "vllm": "openai", + "xunfei": "openai", + "iflytek": "openai", + "xfyun": "openai", + }.get(raw_provider_name, raw_provider_name) + model_name = str(runtime.get("llm_model") or "openai/gpt-4o-mini").strip() + if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"} and model_name and "/" not in model_name: + model_name = f"openai/{model_name}" + + provider_cfg: Dict[str, Any] = {"apiKey": str(runtime.get("api_key") or "").strip()} + api_base = str(runtime.get("api_base") or "").strip() + if api_base: + provider_cfg["apiBase"] = api_base + + channels_cfg: Dict[str, Any] = { + "sendProgress": bool(global_delivery_override.get("sendProgress", runtime.get("send_progress", False))), + "sendToolHints": bool(global_delivery_override.get("sendToolHints", runtime.get("send_tool_hints", False))), + } + existing_config: Dict[str, Any] = {} + config_path = os.path.join(state_nanobot_dir, "config.json") + if os.path.isfile(config_path): + try: + with open(config_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + if isinstance(loaded, dict): + existing_config = loaded + except Exception: + existing_config = {} + + existing_tools = existing_config.get("tools") + tools_cfg: Dict[str, Any] = dict(existing_tools) if isinstance(existing_tools, dict) else {} + native_sandbox_mode = self._normalize_native_sandbox_mode(runtime.get("native_sandbox_mode")) + if native_sandbox_mode == "workspace": + tools_cfg["restrictToWorkspace"] = True + elif native_sandbox_mode == "full_access": + tools_cfg["restrictToWorkspace"] = False + + existing_channels = existing_config.get("channels") + existing_dashboard_cfg = ( + existing_channels.get("dashboard") + if isinstance(existing_channels, dict) and isinstance(existing_channels.get("dashboard"), dict) + else {} + ) + dashboard_cfg: Dict[str, Any] = { + "enabled": True, + "host": "0.0.0.0", + "port": self._dashboard_port_for_bot(bot_id), + "allowFrom": ["*"], + } + for key in ("host", "port", "allowFrom"): + if key in existing_dashboard_cfg: + dashboard_cfg[key] = existing_dashboard_cfg[key] + dashboard_cfg["port"] = self._dashboard_port_for_bot(bot_id) + channels_cfg["dashboard"] = dashboard_cfg + for channel in channels_override: + channel_type = str(channel.get("channel_type") or "").strip().lower() + if not channel_type or channel_type == "dashboard": + continue + extra = channel.get("extra_config") if isinstance(channel.get("extra_config"), dict) else {} + enabled = bool(channel.get("is_active", True)) + external = str(channel.get("external_app_id") or "") + secret = str(channel.get("app_secret") or "") + + if channel_type == "telegram": + channels_cfg["telegram"] = { + "enabled": enabled, + "token": secret, + "proxy": extra.get("proxy", ""), + "replyToMessage": bool(extra.get("replyToMessage", False)), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), + } + continue + + if channel_type == "feishu": + channels_cfg["feishu"] = { + "enabled": enabled, + "appId": external, + "appSecret": secret, + "encryptKey": extra.get("encryptKey", ""), + "verificationToken": extra.get("verificationToken", ""), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), + } + continue + + if channel_type == "dingtalk": + channels_cfg["dingtalk"] = { + "enabled": enabled, + "clientId": external, + "clientSecret": secret, + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), + } + continue + + if channel_type == "slack": + channels_cfg["slack"] = { + "enabled": enabled, + "mode": extra.get("mode", "socket"), + "botToken": external, + "appToken": secret, + "replyInThread": bool(extra.get("replyInThread", True)), + "groupPolicy": extra.get("groupPolicy", "mention"), + "groupAllowFrom": extra.get("groupAllowFrom", []), + "reactEmoji": extra.get("reactEmoji", "eyes"), + } + continue + + if channel_type == "qq": + channels_cfg["qq"] = { + "enabled": enabled, + "appId": external, + "secret": secret, + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), + } + continue + + if channel_type == "email": + channels_cfg["email"] = { + "enabled": enabled, + "consentGranted": bool(extra.get("consentGranted", False)), + "imapHost": extra.get("imapHost", ""), + "imapPort": max(1, min(int(extra.get("imapPort", 993) or 993), 65535)), + "imapUsername": extra.get("imapUsername", ""), + "imapPassword": extra.get("imapPassword", ""), + "imapMailbox": extra.get("imapMailbox", "INBOX"), + "imapUseSsl": bool(extra.get("imapUseSsl", True)), + "smtpHost": extra.get("smtpHost", ""), + "smtpPort": max(1, min(int(extra.get("smtpPort", 587) or 587), 65535)), + "smtpUsername": extra.get("smtpUsername", ""), + "smtpPassword": extra.get("smtpPassword", ""), + "smtpUseTls": bool(extra.get("smtpUseTls", True)), + "smtpUseSsl": bool(extra.get("smtpUseSsl", False)), + "fromAddress": extra.get("fromAddress", ""), + "autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)), + "pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds", 30) or 30)), + "markSeen": bool(extra.get("markSeen", True)), + "maxBodyChars": max(1, int(extra.get("maxBodyChars", 12000) or 12000)), + "subjectPrefix": extra.get("subjectPrefix", "Re: "), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), + } + continue + + channels_cfg[channel_type] = { + "enabled": enabled, + "appId": external, + "appSecret": secret, + **extra, + } + + config_data: Dict[str, Any] = { + "agents": { + "defaults": { + "workspace": workspace_dir, + "model": model_name, + "temperature": float(runtime.get("temperature") or 0.2), + "topP": float(runtime.get("top_p") or 1.0), + "maxTokens": int(runtime.get("max_tokens") or 8192), + } + }, + "providers": {provider_name: provider_cfg}, + "channels": channels_cfg, + } + if tools_cfg: + config_data["tools"] = tools_cfg + self._write_json(config_path, config_data) + + runtime_target = { + "runtime_kind": str(runtime.get("runtime_kind") or "").strip().lower(), + "transport_kind": str(runtime.get("transport_kind") or "").strip().lower(), + "core_adapter": str(runtime.get("core_adapter") or "").strip().lower(), + } + if native_sandbox_mode != "inherit": + runtime_target["native_sandbox_mode"] = native_sandbox_mode + if workspace_root_override: + runtime_target["workspace_root"] = workspace_root_override + if any(runtime_target.values()): + runtime_target_path = os.path.join(state_nanobot_dir, "runtime-target.json") + self._write_json(runtime_target_path, runtime_target) + + bootstrap_files = { + "AGENTS.md": str(runtime.get("agents_md") or DEFAULT_AGENTS_MD).strip() + "\n", + "SOUL.md": str(runtime.get("soul_md") or runtime.get("system_prompt") or DEFAULT_SOUL_MD).strip() + "\n", + "USER.md": str(runtime.get("user_md") or DEFAULT_USER_MD).strip() + "\n", + "TOOLS.md": str(runtime.get("tools_md") or DEFAULT_TOOLS_MD).strip() + "\n", + "IDENTITY.md": str(runtime.get("identity_md") or DEFAULT_IDENTITY_MD).strip() + "\n", + } + for filename, content in bootstrap_files.items(): + file_path = os.path.join(workspace_dir, filename) + with open(file_path, "w", encoding="utf-8") as fh: + fh.write(content) + + return {"status": "ok"} + + @staticmethod + def _normalize_allow_from(raw: Any) -> List[str]: + rows: List[str] = [] + if isinstance(raw, list): + for item in raw: + text = str(item or "").strip() + if text and text not in rows: + rows.append(text) + if not rows: + return ["*"] + return rows + + @staticmethod + def _dashboard_port_for_bot(bot_id: str) -> int: + digest = hashlib.sha1(str(bot_id or "").strip().encode("utf-8")).hexdigest() + return 19000 + (int(digest[:6], 16) % 2000) + + @staticmethod + def _workspace_root_override(runtime_overrides: Dict[str, Any]) -> str: + raw = str(runtime_overrides.get("workspace_root") or "").strip() + if not raw: + return "" + return os.path.abspath(os.path.expanduser(raw)) + + @staticmethod + def _normalize_native_sandbox_mode(raw_value: Any) -> str: + text = str(raw_value or "").strip().lower() + if text in {"workspace", "sandbox", "strict"}: + return "workspace" + if text in {"full_access", "full-access", "danger-full-access", "escape"}: + return "full_access" + return "inherit" + + def _bot_workspace_dir(self, bot_id: str, workspace_root_override: str) -> str: + if not workspace_root_override: + return os.path.abspath(os.path.join(self._host_data_root, str(bot_id or "").strip())) + return os.path.abspath(os.path.join(workspace_root_override, str(bot_id or "").strip())) + + @staticmethod + def _write_json(path: str, payload: Dict[str, Any]) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + + +edge_provision_service: EdgeProvisionService | None = None diff --git a/dashboard-edge/app/services/runtime_service.py b/dashboard-edge/app/services/runtime_service.py new file mode 100644 index 0000000..274f6f1 --- /dev/null +++ b/dashboard-edge/app/services/runtime_service.py @@ -0,0 +1,511 @@ +import json +import os +import shlex +import shutil +import csv +from datetime import datetime, timezone + +import psutil +from fastapi import HTTPException + +from app.core.settings import EDGE_BOTS_WORKSPACE_ROOT, EDGE_NODE_ID, EDGE_NODE_NAME +from app.runtime.base import EdgeRuntimeBackend +from app.runtime.factory import build_edge_runtime_backends, preferred_edge_runtime_kind +from app.schemas.edge import ( + EdgeCommandRequest, + EdgeLogsResponse, + EdgeMonitorEnsureResponse, + EdgeMonitorPacket, + EdgeMonitorPacketsResponse, + EdgeNodeHeartbeatResponse, + EdgeNodeResourcesResponse, + EdgeNodeSelfResponse, + EdgeStatusResponse, + NODE_PROTOCOL_VERSION, +) +from app.schemas.runtime import EdgeStartBotRequest + + +class EdgeRuntimeService: + def __init__(self) -> None: + self._runtime_backends: dict[str, EdgeRuntimeBackend] = { + str(kind).strip().lower(): backend + for kind, backend in build_edge_runtime_backends().items() + if isinstance(kind, str) + } + self._recent_packets: dict[str, list[dict]] = {} + self._packet_counters: dict[str, int] = {} + self._backfilled_bots: set[str] = set() + + def _runtime_kind(self) -> str: + return preferred_edge_runtime_kind(self._runtime_backends) + + def capabilities(self) -> dict: + caps: dict = {"protocol": {"version": NODE_PROTOCOL_VERSION}} + runtime_caps: dict[str, bool] = {} + workspace_caps: dict[str, bool] = {} + monitor_caps: dict[str, bool] = {} + process_caps: dict[str, object] = {} + + for backend in self._runtime_backends.values(): + current = dict(backend.capabilities() if hasattr(backend, "capabilities") else {}) + for key, value in dict(current.get("runtime") or {}).items(): + normalized = str(key or "").strip().lower() + if not normalized: + continue + runtime_caps[normalized] = bool(runtime_caps.get(normalized) or value is True) + for key, value in dict(current.get("workspace") or {}).items(): + normalized = str(key or "").strip() + if not normalized: + continue + workspace_caps[normalized] = bool(workspace_caps.get(normalized) or value is True) + for key, value in dict(current.get("monitor") or {}).items(): + normalized = str(key or "").strip() + if not normalized: + continue + monitor_caps[normalized] = bool(monitor_caps.get(normalized) or value is True) + for key, value in dict(current.get("process") or {}).items(): + normalized = str(key or "").strip() + if normalized: + process_caps[normalized] = value + + if runtime_caps: + caps["runtime"] = runtime_caps + if workspace_caps: + caps["workspace"] = workspace_caps + if monitor_caps: + caps["monitor"] = monitor_caps + if process_caps: + caps["process"] = process_caps + return caps + + async def start_bot(self, *, bot_id: str, payload: EdgeStartBotRequest) -> EdgeStatusResponse: + runtime_kind = self._resolve_runtime_kind(bot_id, preferred=payload.runtime_kind) + backend = self._backend_for_bot(bot_id, preferred=runtime_kind) + self._write_runtime_target( + bot_id=bot_id, + runtime_kind=runtime_kind, + workspace_root=str(payload.workspace_root or "").strip() or None, + ) + success = backend.start_bot( + bot_id=bot_id, + image_tag=str(payload.image_tag or "").strip(), + env_vars=dict(payload.env_vars or {}), + workspace_root=str(payload.workspace_root or "").strip() or None, + native_command=str(payload.native_command or "").strip() or None, + native_workdir=str(payload.native_workdir or "").strip() or None, + cpu_cores=float(payload.cpu_cores), + memory_mb=int(payload.memory_mb), + storage_gb=int(payload.storage_gb), + on_state_change=self._record_monitor_packet, + ) + if not success: + detail = backend.get_last_delivery_error(bot_id) or f"Failed to start bot {bot_id} on dashboard-edge" + raise HTTPException(status_code=500, detail=detail) + return EdgeStatusResponse(status="started") + + def stop_bot(self, *, bot_id: str) -> EdgeStatusResponse: + resolved_kind = self._resolve_runtime_kind(bot_id) + ordered_kinds: list[str] = [] + if resolved_kind: + ordered_kinds.append(resolved_kind) + for kind in self._runtime_backends.keys(): + if kind not in ordered_kinds: + ordered_kinds.append(kind) + for kind in ordered_kinds: + backend = self._runtime_backends.get(kind) + if backend is None: + continue + try: + backend.stop_bot(bot_id) + except Exception: + continue + return EdgeStatusResponse(status="stopped") + + def send_command(self, *, bot_id: str, payload: EdgeCommandRequest) -> EdgeStatusResponse: + backend = self._backend_for_bot(bot_id) + ok = backend.send_command(bot_id, payload.command, media=list(payload.media or [])) + if not ok: + detail = backend.get_last_delivery_error(bot_id) or "command delivery failed" + raise HTTPException(status_code=502, detail=detail) + return EdgeStatusResponse(status="ok") + + def ensure_monitor(self, *, bot_id: str) -> EdgeMonitorEnsureResponse: + backend = self._backend_for_bot(bot_id) + ensured = backend.ensure_monitor(bot_id, self._record_monitor_packet) + return EdgeMonitorEnsureResponse(ensured=bool(ensured)) + + def get_recent_logs(self, *, bot_id: str, tail: int) -> EdgeLogsResponse: + backend = self._backend_for_bot(bot_id) + return EdgeLogsResponse(bot_id=bot_id, logs=backend.get_recent_logs(bot_id, tail=tail)) + + def get_monitor_packets(self, *, bot_id: str, after_seq: int = 0, limit: int = 200) -> EdgeMonitorPacketsResponse: + self._backfill_monitor_packets(bot_id=bot_id) + rows = [ + dict(row) + for row in self._recent_packets.get(bot_id, []) + if int(row.get("seq") or 0) > max(0, int(after_seq or 0)) + ] + rows.sort(key=lambda row: int(row.get("seq") or 0)) + if limit > 0: + rows = rows[: int(limit)] + latest_seq = int(self._packet_counters.get(bot_id, 0) or 0) + return EdgeMonitorPacketsResponse( + protocol_version=NODE_PROTOCOL_VERSION, + node_id=EDGE_NODE_ID, + bot_id=bot_id, + latest_seq=latest_seq, + packets=[ + EdgeMonitorPacket.model_validate( + { + "protocol_version": NODE_PROTOCOL_VERSION, + "node_id": EDGE_NODE_ID, + "bot_id": bot_id, + **row, + } + ) + for row in rows + ], + ) + + def get_runtime_status(self, *, bot_id: str) -> EdgeStatusResponse: + backend = self._backend_for_bot(bot_id) + return EdgeStatusResponse(status=backend.get_bot_status(bot_id)) + + def get_resource_snapshot(self, *, bot_id: str) -> dict: + backend = self._backend_for_bot(bot_id) + snapshot = dict(backend.get_bot_resource_snapshot(bot_id) or {}) + snapshot.setdefault("runtime_kind", self._resolve_runtime_kind(bot_id)) + return snapshot + + def get_node_identity(self) -> EdgeNodeSelfResponse: + resources = self.get_node_resource_summary() + return EdgeNodeSelfResponse( + protocol_version=resources.protocol_version, + node_id=EDGE_NODE_ID, + display_name=EDGE_NODE_NAME, + service="dashboard-edge", + transport_kind="edge", + runtime_kind=self._runtime_kind(), + core_adapter="nanobot", + capabilities=self.capabilities(), + resources=dict(resources.resources or {}), + reported_at=resources.reported_at, + ) + + def get_node_resource_summary(self) -> EdgeNodeResourcesResponse: + cpu_percent = 0.0 + try: + cpu_percent = float(psutil.cpu_percent(interval=None) or 0.0) + except Exception: + cpu_percent = 0.0 + + memory_total = 0 + memory_used = 0 + try: + memory = psutil.virtual_memory() + memory_total = int(getattr(memory, "total", 0) or 0) + memory_used = int(getattr(memory, "used", 0) or 0) + except Exception: + memory_total = 0 + memory_used = 0 + + workspace_limit = 0 + workspace_used = 0 + try: + disk = psutil.disk_usage(EDGE_BOTS_WORKSPACE_ROOT) + workspace_limit = int(getattr(disk, "total", 0) or 0) + workspace_used = int(getattr(disk, "used", 0) or 0) + except Exception: + workspace_limit = 0 + workspace_used = self._calc_workspace_used_bytes() + + cpu_cores = 0.0 + try: + cpu_cores = float(psutil.cpu_count(logical=True) or 0) + except Exception: + cpu_cores = 0.0 + + return EdgeNodeResourcesResponse( + protocol_version=NODE_PROTOCOL_VERSION, + node_id=EDGE_NODE_ID, + display_name=EDGE_NODE_NAME, + transport_kind="edge", + runtime_kind=self._runtime_kind(), + core_adapter="nanobot", + resources={ + "configured_cpu_cores": round(cpu_cores, 2), + "configured_memory_bytes": memory_total, + "configured_storage_bytes": workspace_limit, + "live_cpu_percent": round(cpu_percent, 2), + "live_memory_used_bytes": memory_used, + "live_memory_limit_bytes": memory_total, + "workspace_used_bytes": workspace_used, + "workspace_limit_bytes": workspace_limit, + }, + reported_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + ) + + def heartbeat(self) -> EdgeNodeHeartbeatResponse: + node_resources = self.get_node_resource_summary() + return EdgeNodeHeartbeatResponse( + protocol_version=NODE_PROTOCOL_VERSION, + node_id=EDGE_NODE_ID, + display_name=EDGE_NODE_NAME, + service="dashboard-edge", + transport_kind="edge", + runtime_kind=self._runtime_kind(), + core_adapter="nanobot", + capabilities=self.capabilities(), + resources=dict(node_resources.resources or {}), + reported_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + ) + + def native_preflight(self, *, native_command: str | None = None, native_workdir: str | None = None) -> dict: + raw_command = str(native_command or "").strip() + command_parts: list[str] = [] + parse_error = "" + if raw_command: + command_parts, parse_error = self._parse_native_command(raw_command) + if not raw_command and not command_parts: + backend = self._runtime_backends.get("native") + process_caps = {} + if backend is not None: + process_caps = dict((backend.capabilities() or {}).get("process") or {}) + command_parts = [str(item or "").strip() for item in list(process_caps.get("command") or []) if str(item or "").strip()] + + command_available = bool(command_parts and shutil.which(command_parts[0])) + configured_workdir = str(native_workdir or "").strip() + if configured_workdir: + workdir = os.path.abspath(configured_workdir) + workdir_exists = os.path.isdir(workdir) + else: + workdir = "" + workdir_exists = True + + ok = bool(command_available and workdir_exists) + detail_parts: list[str] = [] + if not command_available: + detail_parts.append("native command not available") + if not workdir_exists: + detail_parts.append("native workdir does not exist") + if parse_error: + detail_parts.append(parse_error) + if not detail_parts: + detail_parts.append("native launcher ready") + + return { + "ok": ok, + "command": command_parts, + "workdir": workdir, + "command_available": command_available, + "workdir_exists": workdir_exists, + "detail": "; ".join(detail_parts), + } + + @staticmethod + def _parse_native_command(raw_command: str) -> tuple[list[str], str]: + text = str(raw_command or "").strip() + if not text: + return [], "" + if text.startswith("[") and text.endswith("]"): + try: + payload = json.loads(text) + if isinstance(payload, list): + rows = [str(item or "").strip() for item in payload if str(item or "").strip()] + if rows: + return rows, "" + return [], "native command JSON list is empty" + except Exception: + return [], "native command JSON is invalid" + if "," in text and any(mark in text for mark in ['"', "'"]): + try: + rows = [str(item or "").strip() for item in next(csv.reader([text], skipinitialspace=True)) if str(item or "").strip()] + if rows: + return rows, "" + except Exception: + pass + try: + rows = [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()] + if rows: + return rows, "" + return [], "native command is empty" + except Exception: + return [], "native command format is invalid" + + def _record_monitor_packet(self, bot_id: str, packet: dict) -> None: + rows = self._recent_packets.setdefault(bot_id, []) + next_seq = int(self._packet_counters.get(bot_id, 0) or 0) + 1 + self._packet_counters[bot_id] = next_seq + captured_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + rows.append( + { + "protocol_version": NODE_PROTOCOL_VERSION, + "node_id": EDGE_NODE_ID, + "bot_id": bot_id, + "seq": next_seq, + "captured_at": captured_at, + "packet": dict(packet or {}), + } + ) + if len(rows) > 200: + del rows[:-200] + + def _backfill_monitor_packets(self, bot_id: str) -> None: + if bot_id in self._backfilled_bots: + return + self._backfilled_bots.add(bot_id) + backend = self._backend_for_bot(bot_id) + for line in backend.get_recent_logs(bot_id, tail=500): + packet = backend.parse_monitor_packet(line) + if packet: + self._record_monitor_packet(bot_id, packet) + + def _backend_for_bot(self, bot_id: str, preferred: str | None = None) -> EdgeRuntimeBackend: + runtime_kind = self._resolve_runtime_kind(bot_id, preferred=preferred) + backend = self._runtime_backends.get(runtime_kind) + if backend is None: + raise HTTPException(status_code=501, detail=f"dashboard-edge runtime is not available: {runtime_kind}") + return backend + + def _resolve_runtime_kind(self, bot_id: str, preferred: str | None = None) -> str: + normalized_preferred = self._normalize_runtime_kind(preferred, allow_empty=True) + if normalized_preferred and normalized_preferred in self._runtime_backends: + return normalized_preferred + + persisted = self._normalize_runtime_kind(self._read_runtime_target(bot_id), allow_empty=True) + if persisted and persisted in self._runtime_backends: + return persisted + + for runtime_kind, backend in self._runtime_backends.items(): + try: + if str(backend.get_bot_status(bot_id) or "").strip().upper() == "RUNNING": + return runtime_kind + except Exception: + continue + return self._runtime_kind() + + @staticmethod + def _normalize_runtime_kind(value: str | None, *, allow_empty: bool = False) -> str: + text = str(value or "").strip().lower() + if allow_empty and not text: + return "" + return text if text in {"docker", "native"} else "docker" + + @staticmethod + def _runtime_target_path(bot_id: str) -> str: + return os.path.join(EDGE_BOTS_WORKSPACE_ROOT, str(bot_id or "").strip(), ".nanobot", "runtime-target.json") + + @staticmethod + def _config_path(bot_id: str) -> str: + return os.path.join(EDGE_BOTS_WORKSPACE_ROOT, str(bot_id or "").strip(), ".nanobot", "config.json") + + def _read_runtime_target(self, bot_id: str) -> str: + payload = self._read_runtime_target_payload(bot_id) + if isinstance(payload, dict): + return str(payload.get("runtime_kind") or "").strip().lower() + return "" + + def _read_runtime_target_payload(self, bot_id: str) -> dict: + for path in self._runtime_target_paths_for_read(bot_id): + if not os.path.isfile(path): + continue + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if isinstance(payload, dict): + return payload + except Exception: + continue + return {} + + def _write_runtime_target(self, *, bot_id: str, runtime_kind: str, workspace_root: str | None = None) -> None: + payload = dict(self._read_runtime_target_payload(bot_id)) + payload["runtime_kind"] = self._normalize_runtime_kind(runtime_kind) + if workspace_root is not None: + normalized_root = str(workspace_root or "").strip() + if normalized_root: + payload["workspace_root"] = os.path.abspath(os.path.expanduser(normalized_root)) + else: + payload.pop("workspace_root", None) + paths = self._runtime_target_paths(bot_id=bot_id, payload=payload) + for path in paths: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + primary = self._runtime_target_path(bot_id) + if primary not in paths and os.path.isfile(primary): + try: + os.remove(primary) + except Exception: + pass + + def _runtime_target_paths(self, *, bot_id: str, payload: dict) -> list[str]: + primary = self._runtime_target_path(bot_id) + workspace_root = str(payload.get("workspace_root") or "").strip() + if workspace_root: + external = os.path.join( + os.path.abspath(os.path.expanduser(workspace_root)), + str(bot_id or "").strip(), + ".nanobot", + "runtime-target.json", + ) + if os.path.abspath(external) != os.path.abspath(primary): + return [external] + return [primary] + + def _runtime_target_paths_for_read(self, bot_id: str) -> list[str]: + primary = self._runtime_target_path(bot_id) + rows: list[str] = [primary] + workspace_root = self._workspace_root_from_config(bot_id) + if workspace_root: + external = os.path.join( + workspace_root, + str(bot_id or "").strip(), + ".nanobot", + "runtime-target.json", + ) + if os.path.abspath(external) != os.path.abspath(primary): + rows.insert(0, external) + return rows + + def _workspace_root_from_config(self, bot_id: str) -> str: + path = self._config_path(bot_id) + if not os.path.isfile(path): + return "" + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + return "" + agents = payload.get("agents") + if not isinstance(agents, dict): + return "" + defaults = agents.get("defaults") + if not isinstance(defaults, dict): + return "" + workspace = str(defaults.get("workspace") or "").strip() + if not workspace: + return "" + normalized_workspace = os.path.abspath(os.path.expanduser(workspace)) + marker = f"{os.sep}{str(bot_id or '').strip()}{os.sep}.nanobot{os.sep}workspace" + if marker in normalized_workspace: + return normalized_workspace.rsplit(marker, 1)[0] + except Exception: + return "" + return "" + + @staticmethod + def _calc_workspace_used_bytes() -> int: + total = 0 + for root, _, files in os.walk(EDGE_BOTS_WORKSPACE_ROOT): + for filename in files: + path = os.path.join(root, filename) + try: + total += int(os.path.getsize(path)) + except Exception: + continue + return total + + +edge_runtime_service = EdgeRuntimeService() diff --git a/dashboard-edge/app/services/state_store_service.py b/dashboard-edge/app/services/state_store_service.py new file mode 100644 index 0000000..39eda9b --- /dev/null +++ b/dashboard-edge/app/services/state_store_service.py @@ -0,0 +1,133 @@ +import json +import os +from typing import Any, Dict, Optional + +from fastapi import HTTPException + + +class EdgeStateStoreService: + _STATE_FILE_MAP = { + "config": ("config.json",), + "env": ("env.json",), + "resources": ("resources.json",), + "cron": ("cron", "jobs.json"), + } + + def __init__(self, *, host_data_root: str) -> None: + self._host_data_root = os.path.abspath(os.path.expanduser(str(host_data_root or "").strip())) + + def read_state(self, *, bot_id: str, state_key: str, workspace_root: Optional[str] = None) -> Dict[str, Any]: + normalized_bot_id = self._normalize_bot_id(bot_id) + normalized_key = self._normalize_state_key(state_key) + path = self._state_file_path(normalized_bot_id, normalized_key, workspace_root=workspace_root) + payload = self._default_payload(normalized_key) + if os.path.isfile(path): + loaded = self._read_json(path) + if isinstance(loaded, dict): + payload = self._normalize_state_payload(normalized_key, loaded) + return {"bot_id": normalized_bot_id, "state_key": normalized_key, "data": payload} + + def write_state( + self, + *, + bot_id: str, + state_key: str, + data: Dict[str, Any], + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + normalized_bot_id = self._normalize_bot_id(bot_id) + normalized_key = self._normalize_state_key(state_key) + payload = self._normalize_state_payload(normalized_key, data if isinstance(data, dict) else {}) + path = self._state_file_path(normalized_bot_id, normalized_key, workspace_root=workspace_root) + self._write_json_atomic(path, payload) + return {"bot_id": normalized_bot_id, "state_key": normalized_key, "data": payload} + + def _state_file_path(self, bot_id: str, state_key: str, *, workspace_root: Optional[str] = None) -> str: + nanobot_root = self._nanobot_root(bot_id, workspace_root=workspace_root) + relative = self._STATE_FILE_MAP[state_key] + return os.path.join(nanobot_root, *relative) + + def _nanobot_root(self, bot_id: str, *, workspace_root: Optional[str] = None) -> str: + configured_workspace_root = str(workspace_root or "").strip() + if configured_workspace_root: + normalized_root = os.path.abspath(os.path.expanduser(configured_workspace_root)) + return os.path.abspath(os.path.join(normalized_root, bot_id, ".nanobot")) + + primary = os.path.abspath(os.path.join(self._host_data_root, bot_id, ".nanobot")) + inferred_workspace_root = self._workspace_root_from_runtime_target(primary) + if inferred_workspace_root: + return os.path.abspath(os.path.join(inferred_workspace_root, bot_id, ".nanobot")) + return primary + + @staticmethod + def _workspace_root_from_runtime_target(primary_nanobot_root: str) -> str: + path = os.path.join(primary_nanobot_root, "runtime-target.json") + if not os.path.isfile(path): + return "" + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + return "" + raw_root = str(payload.get("workspace_root") or "").strip() + if not raw_root: + return "" + return os.path.abspath(os.path.expanduser(raw_root)) + except Exception: + return "" + + @classmethod + def _normalize_state_key(cls, state_key: str) -> str: + normalized = str(state_key or "").strip().lower() + if normalized not in cls._STATE_FILE_MAP: + raise HTTPException(status_code=400, detail=f"unsupported state key: {state_key}") + return normalized + + @staticmethod + def _normalize_bot_id(bot_id: str) -> str: + normalized = str(bot_id or "").strip() + if not normalized: + raise HTTPException(status_code=400, detail="bot_id is required") + return normalized + + @staticmethod + def _default_payload(state_key: str) -> Dict[str, Any]: + if state_key == "cron": + return {"version": 1, "jobs": []} + return {} + + @classmethod + def _normalize_state_payload(cls, state_key: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if state_key == "cron": + normalized = dict(payload if isinstance(payload, dict) else {}) + jobs = normalized.get("jobs") + if not isinstance(jobs, list): + jobs = [] + try: + version = int(normalized.get("version", 1) or 1) + except Exception: + version = 1 + return {"version": max(1, version), "jobs": jobs} + return dict(payload if isinstance(payload, dict) else {}) + + @staticmethod + def _read_json(path: str) -> Dict[str, Any]: + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if isinstance(payload, dict): + return payload + except Exception: + return {} + return {} + + @staticmethod + def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + os.replace(tmp, path) + + +edge_state_store_service: EdgeStateStoreService | None = None diff --git a/dashboard-edge/app/services/workspace_service.py b/dashboard-edge/app/services/workspace_service.py new file mode 100644 index 0000000..39d7db0 --- /dev/null +++ b/dashboard-edge/app/services/workspace_service.py @@ -0,0 +1,441 @@ +import mimetypes +import json +import os +import re +from datetime import datetime +from typing import Any, Dict, Generator, List, Optional + +from fastapi import HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, Response, StreamingResponse + +from app.core.settings import EDGE_ALLOWED_ATTACHMENT_EXTENSIONS, EDGE_UPLOAD_MAX_MB + + +class EdgeWorkspaceService: + def __init__(self, *, host_data_root: str) -> None: + self._host_data_root = host_data_root + + def list_tree( + self, + *, + bot_id: str, + path: Optional[str] = None, + recursive: bool = False, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + root = self._workspace_root(bot_id, workspace_root=workspace_root) + if not os.path.isdir(root): + return {"bot_id": bot_id, "root": root, "cwd": "", "parent": None, "entries": []} + _, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root) + if not os.path.isdir(target): + raise HTTPException(status_code=400, detail="workspace path is not a directory") + cwd = os.path.relpath(target, root).replace("\\", "/") + if cwd == ".": + cwd = "" + parent = None + if cwd: + parent = os.path.dirname(cwd).replace("\\", "/") + if parent == ".": + parent = "" + return { + "bot_id": bot_id, + "root": root, + "cwd": cwd, + "parent": parent, + "entries": self._list_workspace_dir_recursive(target, root) if recursive else self._list_workspace_dir(target, root), + } + + def read_file( + self, + *, + bot_id: str, + path: str, + max_bytes: int = 200000, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="workspace file not found") + safe_max = max(4096, min(int(max_bytes), 1000000)) + with open(target, "rb") as fh: + raw = fh.read(safe_max + 1) + if b"\x00" in raw: + raise HTTPException(status_code=400, detail="binary file is not previewable") + truncated = len(raw) > safe_max + body = raw[:safe_max] if truncated else raw + rel_path = os.path.relpath(target, root).replace("\\", "/") + ext = os.path.splitext(target)[1].lower() + return { + "bot_id": bot_id, + "path": rel_path, + "size": os.path.getsize(target), + "is_markdown": ext in {".md", ".markdown"}, + "truncated": truncated, + "content": body.decode("utf-8", errors="replace"), + } + + def write_markdown( + self, + *, + bot_id: str, + path: str, + content: str, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="workspace file not found") + ext = os.path.splitext(target)[1].lower() + if ext not in {".md", ".markdown"}: + raise HTTPException(status_code=400, detail=f"editing is only supported for markdown files: {ext or '(none)'}") + encoded = str(content or "").encode("utf-8") + if len(encoded) > 2_000_000: + raise HTTPException(status_code=413, detail="markdown file too large to save") + if "\x00" in str(content or ""): + raise HTTPException(status_code=400, detail="markdown content contains invalid null bytes") + self._write_text_atomic(target, str(content or "")) + rel_path = os.path.relpath(target, root).replace("\\", "/") + return { + "bot_id": bot_id, + "path": rel_path, + "size": os.path.getsize(target), + "is_markdown": True, + "truncated": False, + "content": str(content or ""), + } + + async def upload_files( + self, + *, + bot_id: str, + files: List[UploadFile], + path: Optional[str] = None, + workspace_root: Optional[str] = None, + ) -> Dict[str, Any]: + if not files: + raise HTTPException(status_code=400, detail="no files uploaded") + + max_bytes = EDGE_UPLOAD_MAX_MB * 1024 * 1024 + allowed_extensions = set(EDGE_ALLOWED_ATTACHMENT_EXTENSIONS) + + root, upload_dir = self._resolve_workspace_path(bot_id, path or "uploads", workspace_root=workspace_root) + os.makedirs(upload_dir, exist_ok=True) + safe_dir_real = os.path.abspath(upload_dir) + if os.path.commonpath([root, safe_dir_real]) != root: + raise HTTPException(status_code=400, detail="invalid upload target path") + + rows: List[Dict[str, Any]] = [] + for upload in files: + original = (upload.filename or "upload.bin").strip() or "upload.bin" + name = os.path.basename(original).replace("\\", "_").replace("/", "_") + name = re.sub(r"[^\w.\-()+@ ]+", "_", name) + if not name: + name = "upload.bin" + ext = str(os.path.splitext(name)[1] or "").strip().lower() + if allowed_extensions and ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"File '{name}' extension is not allowed. Allowed: {', '.join(sorted(allowed_extensions))}", + ) + + abs_path = os.path.join(safe_dir_real, name) + if os.path.exists(abs_path): + base, file_ext = os.path.splitext(name) + name = f"{base}-{int(datetime.utcnow().timestamp())}{file_ext}" + abs_path = os.path.join(safe_dir_real, name) + + total_size = 0 + try: + with open(abs_path, "wb") as fh: + while True: + chunk = await upload.read(1024 * 1024) + if not chunk: + break + total_size += len(chunk) + if total_size > max_bytes: + raise HTTPException( + status_code=413, + detail=f"File '{name}' too large (max {EDGE_UPLOAD_MAX_MB}MB)", + ) + fh.write(chunk) + except HTTPException: + if os.path.exists(abs_path): + os.remove(abs_path) + raise + except OSError as exc: + if os.path.exists(abs_path): + os.remove(abs_path) + raise HTTPException( + status_code=500, + detail=f"Failed to write file '{name}': {exc.strerror or str(exc)}", + ) + except Exception: + if os.path.exists(abs_path): + os.remove(abs_path) + raise HTTPException(status_code=500, detail=f"Failed to upload file '{name}'") + finally: + await upload.close() + + rel = os.path.relpath(abs_path, root).replace("\\", "/") + rows.append({"name": name, "path": rel, "size": total_size}) + + return {"bot_id": bot_id, "files": rows} + + def serve_file( + self, + *, + bot_id: str, + path: str, + download: bool, + request: Request, + workspace_root: Optional[str] = None, + ) -> Response: + _root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="File not found") + + media_type, _ = mimetypes.guess_type(target) + range_header = request.headers.get("range", "") + if range_header and not download: + return self._build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header) + + common_headers = {"Accept-Ranges": "bytes"} + if download: + return FileResponse( + target, + media_type=media_type or "application/octet-stream", + filename=os.path.basename(target), + headers=common_headers, + ) + return FileResponse(target, media_type=media_type or "application/octet-stream", headers=common_headers) + + def purge_bot_workspace(self, *, bot_id: str, workspace_root: Optional[str] = None) -> Dict[str, Any]: + deleted = False + state_root = self._state_bot_root(bot_id, workspace_root=workspace_root) + workspace_bot_root = self._workspace_bot_root(bot_id, workspace_root=workspace_root) + targets = [state_root] + if os.path.abspath(workspace_bot_root) != os.path.abspath(state_root): + targets.append(workspace_bot_root) + import shutil + + for target in targets: + if not target or target in {"/", "."}: + raise HTTPException(status_code=400, detail="invalid bot workspace root") + if os.path.isdir(target): + shutil.rmtree(target, ignore_errors=True) + deleted = True + return {"bot_id": str(bot_id or "").strip(), "deleted": deleted} + + def _workspace_root(self, bot_id: str, workspace_root: Optional[str] = None) -> str: + return os.path.abspath(os.path.join(self._workspace_bot_root(bot_id, workspace_root=workspace_root), ".nanobot", "workspace")) + + def _state_bot_root(self, bot_id: str, workspace_root: Optional[str] = None) -> str: + configured_workspace_root = str(workspace_root or "").strip() + if configured_workspace_root: + normalized_root = os.path.abspath(os.path.expanduser(configured_workspace_root)) + return os.path.abspath(os.path.join(normalized_root, str(bot_id or "").strip())) + return os.path.abspath(os.path.join(self._host_data_root, str(bot_id or "").strip())) + + def _runtime_target_path(self, bot_id: str) -> str: + return os.path.join(self._state_bot_root(bot_id), ".nanobot", "runtime-target.json") + + def _runtime_target_payload(self, bot_id: str) -> Dict[str, Any]: + path = self._runtime_target_path(bot_id) + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if isinstance(payload, dict): + return payload + except Exception: + return {} + return {} + + def _workspace_bot_root(self, bot_id: str, workspace_root: Optional[str] = None) -> str: + configured_workspace_root = str(workspace_root or "").strip() + if configured_workspace_root: + normalized_root = os.path.abspath(os.path.expanduser(configured_workspace_root)) + return os.path.abspath(os.path.join(normalized_root, str(bot_id or "").strip())) + payload = self._runtime_target_payload(bot_id) + workspace_root = str(payload.get("workspace_root") or "").strip() + if workspace_root: + normalized_root = os.path.abspath(os.path.expanduser(workspace_root)) + return os.path.abspath(os.path.join(normalized_root, str(bot_id or "").strip())) + from_config = self._workspace_bot_root_from_config(bot_id) + if from_config: + return from_config + return self._state_bot_root(bot_id) + + def _workspace_bot_root_from_config(self, bot_id: str) -> str: + config_path = os.path.join(self._state_bot_root(bot_id), ".nanobot", "config.json") + if not os.path.isfile(config_path): + return "" + try: + with open(config_path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + return "" + agents = payload.get("agents") + if not isinstance(agents, dict): + return "" + defaults = agents.get("defaults") + if not isinstance(defaults, dict): + return "" + workspace = str(defaults.get("workspace") or "").strip() + if not workspace: + return "" + normalized_workspace = os.path.abspath(os.path.expanduser(workspace)) + if normalized_workspace.endswith("/.nanobot/workspace"): + return os.path.abspath(os.path.dirname(os.path.dirname(normalized_workspace))) + marker = f"{os.sep}.nanobot{os.sep}workspace" + if marker in normalized_workspace: + return os.path.abspath(normalized_workspace.split(marker, 1)[0]) + except Exception: + return "" + return "" + + def _resolve_workspace_path( + self, + bot_id: str, + rel_path: Optional[str] = None, + workspace_root: Optional[str] = None, + ) -> tuple[str, str]: + root = self._workspace_root(bot_id, workspace_root=workspace_root) + rel = (rel_path or "").strip().replace("\\", "/") + target = os.path.abspath(os.path.join(root, rel)) + if os.path.commonpath([root, target]) != root: + raise HTTPException(status_code=400, detail="invalid workspace path") + return root, target + + @staticmethod + def _ctime_iso(stat: os.stat_result) -> str: + ts = getattr(stat, "st_birthtime", None) + if ts is None: + ts = getattr(stat, "st_ctime", None) + try: + return datetime.utcfromtimestamp(float(ts)).isoformat() + "Z" + except Exception: + return datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z" + + @staticmethod + def _write_text_atomic(target: str, content: str) -> None: + os.makedirs(os.path.dirname(target), exist_ok=True) + tmp = f"{target}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(content) + os.replace(tmp, target) + + @staticmethod + def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]: + with open(target, "rb") as fh: + fh.seek(start) + remaining = end - start + 1 + while remaining > 0: + chunk = fh.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + def _build_ranged_workspace_response(self, target: str, media_type: str, range_header: str) -> Response: + file_size = os.path.getsize(target) + range_match = re.match(r"bytes=(\d*)-(\d*)", range_header.strip()) + if not range_match: + raise HTTPException(status_code=416, detail="Invalid range") + + start_raw, end_raw = range_match.groups() + if start_raw == "" and end_raw == "": + raise HTTPException(status_code=416, detail="Invalid range") + + if start_raw == "": + length = int(end_raw) + if length <= 0: + raise HTTPException(status_code=416, detail="Invalid range") + start = max(file_size - length, 0) + end = file_size - 1 + else: + start = int(start_raw) + end = int(end_raw) if end_raw else file_size - 1 + + if start >= file_size or start < 0: + raise HTTPException(status_code=416, detail="Requested range not satisfiable") + end = min(end, file_size - 1) + if end < start: + raise HTTPException(status_code=416, detail="Requested range not satisfiable") + + content_length = end - start + 1 + headers = { + "Accept-Ranges": "bytes", + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(content_length), + } + return StreamingResponse( + self._stream_file_range(target, start, end), + status_code=206, + media_type=media_type or "application/octet-stream", + headers=headers, + ) + + def _list_workspace_dir(self, path: str, root: str) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower())) + for name in names: + if name in {".DS_Store"}: + continue + abs_path = os.path.join(path, name) + stat = os.stat(abs_path) + rows.append( + { + "name": name, + "path": os.path.relpath(abs_path, root).replace("\\", "/"), + "type": "dir" if os.path.isdir(abs_path) else "file", + "size": stat.st_size if os.path.isfile(abs_path) else None, + "ext": os.path.splitext(name)[1].lower() if os.path.isfile(abs_path) else "", + "ctime": self._ctime_iso(stat), + "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", + } + ) + return rows + + def _list_workspace_dir_recursive(self, path: str, root: str) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for walk_root, dirnames, filenames in os.walk(path): + dirnames.sort(key=lambda v: v.lower()) + filenames.sort(key=lambda v: v.lower()) + for name in dirnames: + if name in {".DS_Store"}: + continue + abs_path = os.path.join(walk_root, name) + stat = os.stat(abs_path) + rows.append( + { + "name": name, + "path": os.path.relpath(abs_path, root).replace("\\", "/"), + "type": "dir", + "size": None, + "ext": "", + "ctime": self._ctime_iso(stat), + "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", + } + ) + for name in filenames: + if name in {".DS_Store"}: + continue + abs_path = os.path.join(walk_root, name) + stat = os.stat(abs_path) + rows.append( + { + "name": name, + "path": os.path.relpath(abs_path, root).replace("\\", "/"), + "type": "file", + "size": stat.st_size, + "ext": os.path.splitext(name)[1].lower(), + "ctime": self._ctime_iso(stat), + "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", + } + ) + return rows + + +edge_workspace_service: EdgeWorkspaceService | None = None diff --git a/dashboard-edge/main.py b/dashboard-edge/main.py new file mode 100644 index 0000000..9c63991 --- /dev/null +++ b/dashboard-edge/main.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from app.main import app + + +if __name__ == "__main__": + import logging + import uvicorn + + from app.core.settings import EDGE_ACCESS_LOG, EDGE_HOST, EDGE_LOG_LEVEL, EDGE_PORT, EDGE_RELOAD + + for name in ("httpx", "httpcore", "uvicorn.access", "watchfiles.main", "watchfiles.watcher"): + logging.getLogger(name).setLevel(logging.WARNING) + + edge_root = Path(__file__).resolve().parent + reload_dirs = [str(edge_root), str(edge_root / "app")] if EDGE_RELOAD else None + uvicorn.run( + "app.main:app", + host=EDGE_HOST, + port=EDGE_PORT, + reload=EDGE_RELOAD, + reload_dirs=reload_dirs, + log_level=EDGE_LOG_LEVEL, + access_log=EDGE_ACCESS_LOG, + ) diff --git a/dashboard-edge/requirements.txt b/dashboard-edge/requirements.txt new file mode 100644 index 0000000..e78ed26 --- /dev/null +++ b/dashboard-edge/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.110.0 +uvicorn==0.27.1 +pydantic==2.6.3 +python-dotenv==1.0.1 +httpx==0.27.0 +python-multipart==0.0.9 +docker==7.0.0 +requests==2.31.0 +psutil==5.9.8 diff --git a/design/dashboard-nanobot-architecture.png b/design/dashboard-nanobot-architecture.png new file mode 100644 index 0000000..28e255a Binary files /dev/null and b/design/dashboard-nanobot-architecture.png differ diff --git a/design/edge-phase-report.md b/design/edge-phase-report.md new file mode 100644 index 0000000..a57507e --- /dev/null +++ b/design/edge-phase-report.md @@ -0,0 +1,97 @@ +# Edge Phase Report + +Date: 2026-03-20 + +This phase focused on turning `dashboard-edge` from a thin remote stub into a real execution-plane service with a stable protocol and a native runtime skeleton. + +## Completed + +### 1. Execution-plane service extracted + +- `dashboard-edge` now exists as a standalone deployable service. +- It owns node identity, heartbeat, node resources, command delivery, logs, workspace, and monitor packet handling. + +### 2. Protocol stabilized + +- Heartbeat, node self, node resources, and monitor packets now share a versioned node protocol. +- Node identity fields are consistent across backend and edge. +- Central backend stores node `capabilities`, `resources`, and `last_seen_at`. + +### 3. Monitor packets are first-class + +- Edge can expose stored monitor packets. +- Backend can poll and persist those packets after command delivery. +- Startup recovery can backfill pending edge conversations from recent logs. + +### 4. Runtime abstraction introduced + +- Edge execution now routes through `EdgeRuntimeBackend`. +- Docker execution is behind the runtime backend abstraction. +- Native execution has a real skeleton instead of a pure stub. + +### 5. Native executor skeleton implemented + +- Native runtime can launch a local child process. +- It manages stdout capture, log persistence, process status, command delivery, and resource snapshot reporting. +- Dashboard chat URL and native launch command are configurable through environment variables. + +### 6. Node management is operational + +- Nodes can be registered, tested, refreshed, and displayed with protocol/resource data. +- The `local-edge` node is now a usable test target. +- A separate `local-edge-native` node is now running against a real native launcher on port `8011`. + +### 7. Deploy / migrate flow is now explicit + +- A dedicated `POST /api/bots/{bot_id}/deploy` flow now handles cross-node migration and redeploy. +- Basic bot editing no longer accepts execution-target or image switching fields. +- The node home page now exposes a real deploy / migrate modal instead of a design-only placeholder. + +### 8. Native launcher defaults are wired + +- `dashboard-edge` now auto-detects the local `nanobot-v0.1.4-post5` virtualenv when `EDGE_NATIVE_COMMAND` is not explicitly configured. +- Native bot startup now passes per-bot `--config` and `--workspace`, so instances no longer fall back to the global `~/.nanobot/config.json`. +- A dedicated `scripts/dev-edge-native.sh` helper starts a native edge node as `local-edge-native`. + +## Still To Do + +### 1. Real native bot contract + +- Validate a real Bot lifecycle on `local-edge-native` without disrupting the existing `local-edge` Docker regression target. +- Verify the dashboard channel injection path during a full assistant turn, not only node health and capability probing. +- Continue validating per-bot dashboard port or URL selection when multiple native bots run on one node. + +### 2. Deploy flow hardening + +- Add richer preflight validation and clearer failure surfacing for edge reachability before switching a Bot. +- Consider a deploy preview / diff step for workspace sync and image changes. +- Extend the flow when a single node eventually supports multiple runtime profiles. + +### 3. Node-level resource telemetry + +- Improve native runtime resource accounting with more precise metrics. +- Consider node heartbeat frequency and health thresholds. + +### 4. Reverse-connect transport + +- The design still prefers node-initiated connection to the central backend for remote machines. +- That transport is not fully implemented yet. + +### 5. Full remote native rollout + +- The native skeleton is in place, but real remote native nodes still need end-to-end validation. +- Existing test bot `unis_bot_a01` can be used as a regression target when those pieces are connected. + +## Verification + +- Backend and edge Python modules compile cleanly. +- `dashboard-edge` can report protocol-versioned node identity and resources. +- `local-edge` is already visible and testable from the central dashboard. +- `local-edge-native` is registered, online, and reports `runtime.native=true` with the real launcher command. +- Frontend build passes with the deploy / migrate modal wired into the active node home page. + +## Notes + +- `direct` remains the local fallback path. +- `edge` is now the intended execution-plane abstraction for both Docker and future Native nodes. +- The long-term goal remains: central dashboard as control plane, `dashboard-edge` as execution plane. diff --git a/design/refactor-modularization-roadmap.md b/design/refactor-modularization-roadmap.md new file mode 100644 index 0000000..c2c6dbd --- /dev/null +++ b/design/refactor-modularization-roadmap.md @@ -0,0 +1,722 @@ +# Dashboard Nanobot 模块化重构与远端桥接演进方案 + +本文档用于指导当前项目的结构性重构,并为后续“支持同机/远端龙虾 + Docker/Native 双运行模式”升级提前抽离边界。 + +目标不是一次性大改所有代码,而是先把未来 2 个核心问题理顺: + +- 当前前端/后端过于集中,后续功能迭代成本越来越高 +- 执行层默认绑定“本机 Docker + 本机文件系统”,无法自然扩展到远端与 Native 模式 + +## 1. 结论先行 + +### 1.1 前端必须拆,但优先做“页面内模块化”,不是重写 UI + +当前前端最大的问题不是样式,而是页面级组件承担了太多职责: + +- 页面状态 +- API 调用 +- 业务编排 +- 视图渲染 +- 弹窗 +- 文件预览 +- 表单保存 +- WebSocket/轮询 + +因此,前端应优先按“页面 -> 业务区块 -> 通用能力”三层拆分。 + +### 1.2 后端必须拆代码结构,但不建议现在把中央 backend 再拆成多个部署服务 + +当前 `backend/main.py` 已经承担: + +- API 路由 +- Bot 生命周期 +- Workspace 文件操作 +- Runtime 状态判断 +- 日志/监控 +- Provider 测试 +- 平台默认值拼装 + +这会让未来远端执行支持非常困难。 + +建议: + +- 现在:先把 `backend` 按领域和 provider 分层拆开,保持 **一个 central backend 服务** +- 后续:新增一个 **独立部署的桥接服务 `dashboard-edge`** + +也就是说: + +- 中央控制面 backend:保留,继续作为统一 API、数据库、权限、审计入口 +- 执行桥接层:新增独立服务,不再内嵌在 central backend 中 + +### 1.3 为了支持远端龙虾,必须引入桥接服务,但不需要修改 nanobot 核心 + +推荐结构: + +- `dashboard-backend` + - 控制平面 + - 统一前端 API + - DB / 权限 / 审计 / 聚合视图 +- `dashboard-edge` + - 执行平面 + - 部署在本机或远端节点 + - 管理该节点上的 Docker / Native Bot + - 与 nanobot 的 `dashboard.py` channel 通信 + +这是未来最稳的演进方向。 + +### 1.4 `dashboard-edge` 应作为执行节点的标准组件 + +长期目标不是“只有远端机器需要 edge”,而是: + +- 任何实际运行 Bot 的节点,都部署一个 `dashboard-edge` +- 同机部署场景下,central backend 与 edge 可以位于同一台机器 +- 远端部署场景下,在远端 Bot 所在机器部署 edge + +也就是说: + +- `dashboard-backend` 负责控制平面 +- `dashboard-edge` 负责执行平面 +- 一个节点一个 edge,不是一个 Bot 一个 edge + +这样本机与远端最终会走同一条执行链路,避免长期维护两套调用路径。 + +### 1.5 `dashboard-edge` 的长期职责不应只绑定 nanobot + +当前第一阶段仍以 nanobot 为主,因为现有接入基础已经存在: + +- workspace 生成 +- `dashboard.py` channel +- 运行态日志解析 + +但从架构角度,`dashboard-edge` 应定位为“Bot Core Adapter Host”,即: + +- edge 负责对接不同 Bot 核心 +- 每类核心通过 adapter/executor 做本地适配 +- 对上统一输出 dashboard 可理解的生命周期、命令、工作区、日志与状态协议 + +因此,未来可以扩展出: + +- `NanobotCoreAdapter` +- `LobsterCoreAdapter` +- 其它兼容 Bot Core Adapter + +控制面不需要理解不同核心的内部细节,只需要依赖统一 provider 契约。 + +## 2. 当前项目的主要结构问题 + +### 2.1 Frontend 问题 + +- `frontend/src/modules/dashboard/BotDashboardModule.tsx` 体量过大,已经承担多个页面级职责 +- `BotWizardModule.tsx` 同时负责创建流程、provider 测试、五个 MD 文件编辑 +- `PlatformDashboardPage.tsx` 混合平台设置、资源视图、Bot 管理视图 +- 页面内部状态与 API 编排严重耦合,局部重构成本很高 +- 公共能力分散: + - Markdown 编辑器 + - workspace markdown 渲染 + - timezone 选项 + - page size 缓存 + - bot 访问工具 + +### 2.2 Backend 问题 + +- `backend/main.py` 是单体入口,所有领域逻辑都堆在同一文件 +- 运行时逻辑默认直接依赖本机资源: + - Docker + - 本地工作区 + - 本地 dashboard channel +- 路由层和业务层耦合严重 +- 未来引入 remote/native 时,如果继续在 `main.py` 里堆条件分支,会快速失控 + +### 2.3 未来升级的真正分界线 + +本质上项目需要明确区分: + +- 控制平面 +- 执行平面 +- nanobot 接入层 + +目前这三者是混在一起的。 + +## 3. 目标架构 + +### 3.1 总体分层 + +推荐把系统拆成 4 层: + +1. `frontend` + - 页面、组件、交互、状态编排 +2. `dashboard-backend` + - 统一 API、鉴权、DB、Provider 路由、审计 +3. `dashboard-edge` + - 本机或远端节点执行代理 +4. `nanobot + dashboard.py` + - Bot 本体,不修改核心 + +### 3.2 控制平面 vs 执行平面 + +#### 控制平面 + +职责: + +- 统一 UI +- 统一 API +- 节点注册与节点状态 +- Bot 元数据存储 +- 权限与访问控制 +- 操作审计 +- 运行状态聚合 + +#### 执行平面 + +职责: + +- 启停 Bot +- 管理 Docker / Host 进程 +- 读写本机工作区 +- 注入 dashboard channel 配置 +- 与 Bot 的本地 dashboard channel 通信 +- 收集运行日志与事件 + +补充原则: + +- 执行平面的核心宿主是 `dashboard-edge` +- edge 内部可以再通过 core adapter 对不同 Bot 核心做协议适配 +- central backend 不直接理解某个具体核心的启动脚本、端口、日志细节 + +### 3.3 支持矩阵 + +最终要支持 4 种执行模式: + +- Local + Docker +- Local + Native +- Remote + Docker +- Remote + Native + +这 4 种模式在控制面前端和 API 层应该尽量表现一致,只在 Provider/Executor 层分流。 + +## 4. Backend 是否需要拆分 + +### 4.1 代码结构上:必须拆 + +必须从当前单文件/弱分层状态拆成: + +- API 路由层 +- 领域服务层 +- Provider 层 +- 基础设施层 + +否则未来远端支持几乎只能靠 `if local else remote` 的条件分支硬堆。 + +### 4.2 部署形态上:中央 backend 不建议继续拆成多个控制面服务 + +不建议现在把 central backend 再拆成: + +- bot-api +- workspace-api +- monitor-api + +原因: + +- 当前规模还不值得引入多控制面服务复杂度 +- 主要痛点不是“服务数量不足”,而是“执行逻辑没抽象” +- 真正需要新增的,是独立的执行代理服务 `dashboard-edge` + +### 4.3 最终建议 + +- 保留一个 central backend +- 新增一个 edge service +- central backend 内部彻底模块化 + +## 5. Backend 目标目录建议 + +建议逐步演进为: + +```text +backend/ + app/ + main.py + dependencies.py + lifespan.py + api/ + bots.py + workspace.py + monitor.py + images.py + platform.py + topics.py + nodes.py + domain/ + bots/ + service.py + runtime_service.py + workspace_service.py + schemas.py + platform/ + service.py + topics/ + service.py + nodes/ + service.py + providers/ + runtime/ + base.py + local.py + remote.py + workspace/ + base.py + local.py + remote.py + provision/ + base.py + local.py + remote.py + infra/ + docker/ + manager.py + workspace/ + files.py + nanobot/ + config_manager.py + dashboard_channel_client.py + persistence/ + database.py + repositories/ + settings/ + config.py + models/ + schemas/ + services/ +``` + +### 5.1 关键原则 + +- `api/` 只做 HTTP 协议转换,不做业务编排 +- `domain/` 承担业务规则 +- `providers/` 负责本机/远端、多运行模式适配 +- `infra/` 负责 Docker、文件系统、dashboard channel、数据库等底层细节 + +## 6. Edge 服务目标目录建议 + +新增独立目录: + +```text +edge/ + main.py + api/ + bots.py + workspace.py + monitor.py + health.py + executors/ + base.py + docker_executor.py + native_executor.py + adapters/ + dashboard_channel.py + log_parser.py + workspace/ + service.py + runtime/ + service.py + settings.py +``` + +### 6.1 Edge 的职责边界 + +只做“本节点执行代理”,不做: + +- 全局权限模型 +- 多节点聚合 +- 平台配置中心 +- 中央数据库业务 + +并且建议把“核心适配”显式纳入 edge 边界: + +- edge 内部管理 `CoreAdapter` +- 负责把不同核心的运行方式转换成统一执行接口 +- central backend 不直接依赖某个核心的私有实现 + +## 7. Frontend 目标结构建议 + +前端建议按“页面/区块/共享能力”拆,而不是继续让超大页面组件承担一切。 + +推荐结构: + +```text +frontend/src/ + app/ + routes/ + providers/ + pages/ + dashboard/ + onboarding/ + platform/ + image-factory/ + widgets/ + bot-list/ + bot-chat/ + workspace-panel/ + bot-settings/ + topic-feed/ + platform-overview/ + features/ + bot-control/ + bot-editor/ + workspace-preview/ + skill-install/ + mcp-config/ + topic-config/ + cron-config/ + entities/ + bot/ + workspace/ + topic/ + platform/ + node/ + shared/ + api/ + ui/ + hooks/ + lib/ + markdown/ + i18n/ +``` + +### 7.1 优先拆分的页面 + +#### `BotDashboardModule` + +优先拆成: + +- `BotHeader` +- `BotControlBar` +- `BotConversationPanel` +- `BotWorkspacePanel` +- `BotRuntimePanel` +- `BotSettingsModals` +- `WorkspacePreviewModal` +- `BotSkillSection` +- `TopicFeedSection` + +并把状态和副作用抽入 hooks: + +- `useBotRuntime` +- `useWorkspaceBrowser` +- `useBotEditor` +- `useBotConversation` +- `useBotMonitorStream` + +#### `BotWizardModule` + +优先拆成: + +- `WizardBaseStep` +- `WizardModelStep` +- `WizardAgentFilesStep` +- `WizardSummaryStep` +- `useBotWizardForm` +- `useProviderTest` + +#### `PlatformDashboardPage` + +优先拆成: + +- `PlatformOverviewPanel` +- `PlatformBotsPanel` +- `PlatformSettingsPanel` +- `PlatformUsagePanel` + +## 8. Provider 抽象建议 + +### 8.1 RuntimeProvider + +统一接口: + +- `start_bot` +- `stop_bot` +- `restart_bot` +- `send_command` +- `get_status` +- `get_recent_logs` +- `stream_monitor` +- `get_resource_snapshot` + +### 8.2 WorkspaceProvider + +统一接口: + +- `list_tree` +- `read_file` +- `write_markdown` +- `upload_files` +- `download_file` + +### 8.3 ProvisionProvider + +统一接口: + +- `create_bot` +- `delete_bot` +- `upgrade_bot` +- `sync_config` + +### 8.4 实现矩阵 + +- `LocalDockerRuntimeProvider` +- `LocalNativeRuntimeProvider` +- `RemoteDockerRuntimeProvider` +- `RemoteNativeRuntimeProvider` + +但对上层 API 来说,尽量只感知: + +- `LocalRuntimeProvider` +- `RemoteRuntimeProvider` + +具体 Docker / Native 再由 provider 内部选择 executor。 + +## 9. 数据模型建议 + +当前 `BotInstance` 不足以表达远端执行目标,需要新增节点维度。 + +### 9.1 建议新增 Node 实体 + +建议字段: + +- `id` +- `name` +- `endpoint` +- `auth_type` +- `auth_secret_ref` +- `enabled` +- `status` +- `capabilities_json` +- `last_heartbeat_at` + +### 9.2 BotInstance 建议新增字段 + +- `node_id` +- `runtime_kind` + - `docker` + - `native` +- `location_kind` + - `local` + - `remote` + +或者简化为: + +- `node_id` +- `runtime_kind` + +其中: + +- `node_id = local` 表示同服务器 +- 其它 `node_id` 表示远端节点 + +### 9.3 迁移策略 + +现有数据默认迁移为: + +- `node_id = local` +- `runtime_kind = docker` + +保证老实例零感知升级。 + +## 10. 与 nanobot 的关系 + +原则保持不变: + +- 不修改 nanobot 核心 +- 继续复用 `dashboard.py` +- 由 dashboard 侧负责桥接与配置注入 + +具体职责: + +- central backend 不直接操作 nanobot +- edge 负责: + - 生成/更新 `.nanobot/config.json` + - 确保 `channels.dashboard` 注入正确 + - 调用 dashboard channel + +## 11. 分阶段实施路线 + +### Phase 1:前端拆大文件,不改 API + +目标: + +- 仅拆页面组件、hooks、services +- API 不变 +- 行为不变 + +优先级: + +- `BotDashboardModule` +- `BotWizardModule` +- `PlatformDashboardPage` + +### Phase 2:后端拆主文件,不改功能 + +目标: + +- 从 `backend/main.py` 中拆出: + - bot lifecycle 路由 + - workspace 路由 + - bot service + - workspace service + - runtime service +- 行为保持不变 + +### Phase 3:引入 Provider 抽象,保留本机 Docker + +目标: + +- 在不改变当前功能的前提下,把执行逻辑走 provider +- 当前 provider 先只实现 local docker + +这是最关键的“抽骨架”阶段。 + +### Phase 4:补 Local Native 模式 + +目标: + +- 支持同机宿主机直装 +- 通过 `NativeExecutor` 管理进程/端口/workspace + +### Phase 5:新增 `dashboard-edge` + +目标: + +- 远端节点部署 edge 服务 +- central backend 引入 remote provider +- 不改前端 API,只增加节点/运行模式字段 + +### Phase 6:前端补节点视图 + +目标: + +- 节点管理页 +- Bot 运行位置与运行模式选择 +- 节点资源与健康状态展示 + +### Phase 7:本机执行路径统一切到 edge + +目标: + +- 逐步让同机 Docker / Native 也通过本机 edge 执行 +- central backend 不再保留长期的“本机直连执行层” +- 最终形成“所有执行节点统一经由 edge”的稳定结构 + +## 12. 本轮重构建议的落地顺序 + +如果从今天开始进入真正改造,建议按下面顺序推进: + +1. 先拆前端大页面 +2. 再拆 backend main.py +3. 然后引入 provider interface +4. 再实现 local native +5. 最后接入 remote edge + +原因: + +- 页面和 `main.py` 的可维护性问题已经是当前痛点 +- provider 抽象是远端能力的必要前提 +- native 支持可以提前验证抽象是否合理 +- remote edge 是最后一公里,不应该在结构没理顺前硬接 + +## 13. 建议的下一步动作 + +下一步不要直接开始“远端龙虾支持”的业务开发,而应先做一轮基础重构: + +### 13.1 前端第一刀 + +从 `BotDashboardModule` 开始,拆成: + +- workspace +- conversation +- runtime +- settings modal +- skills/topic + +### 13.2 后端第一刀 + +从 `backend/main.py` 中先抽 3 个领域: + +- bots lifecycle +- workspace +- bot runtime snapshot / config sync + +### 13.3 provider 第一版 + +先定义接口,不着急做 remote: + +- `RuntimeProvider` +- `WorkspaceProvider` +- `ProvisionProvider` + +并用当前本机逻辑实现 `Local*Provider`。 + +--- + +本方案的核心思想只有一句话: + +**中央 backend 保持控制平面,新增 edge 作为执行平面;先做代码结构分层,再做远端与 native 支持。** + +## 14. 为什么不是一开始就直接拆成 backend + edge + +这个问题的关键不在于“是否值得上 edge”,而在于“当前代码是否已经具备承接 edge 的稳定边界”。 + +答案是:**方向上应该从一开始就以 backend + edge 为目标,但工程实施上不应该在现有单体逻辑不拆的情况下直接硬接 edge。** + +原因如下。 + +### 14.1 当前 central backend 还没有稳定的 provider 边界 + +如果现在直接上 edge,但 central backend 仍然保持现状,那么会出现: + +- 本机逻辑继续直连 Docker / 文件系统 +- 远端逻辑走 edge +- API 路由层同时理解本机细节和远端细节 + +结果会形成两套执行路径: + +- local direct +- remote edge + +这会让后续统一 Native、统一工作区、统一日志协议变得更贵。 + +### 14.2 先拆结构不是绕路,而是在降低总成本 + +先做结构重构的价值是: + +- 把当前隐式耦合改成显式边界 +- 先定义 provider / executor / adapter 契约 +- 为 edge 预留稳定接入点 + +这样后续接 edge 是“接到标准接口上”,不是“插进一团已有业务里”。 + +### 14.3 如果现在直接硬上 edge,代价反而更大 + +直接开做 edge 的风险: + +- central backend 仍然需要保留大量本机直连逻辑 +- 远端功能会复制一套相似业务编排 +- 前端状态和 API 语义可能被迫提前扭曲 +- 最后仍然要回头拆 `main.py` 和超大页面组件 + +也就是说,不是“现在拆结构,后面代价更大”,而是: + +- **如果现在不先拆结构,后面引入 edge 的总代价更大** + +### 14.4 推荐理解方式 + +正确顺序不是: + +- 先重构 +- 再考虑 edge + +而是: + +- 先以 edge 为最终目标设计边界 +- 再做最小必要的结构重构 +- 然后把 edge 接入这些边界 + +因此,这两件事不是对立关系,而是前后依赖关系。 diff --git a/design/remote-node-management.md b/design/remote-node-management.md index 3a72180..a2ae68f 100644 --- a/design/remote-node-management.md +++ b/design/remote-node-management.md @@ -64,6 +64,14 @@ flowchart LR - 不扩展 nanobot 去理解“远端管理”。 - 不要求面板直接连接远端 Bot 的文件系统或 Docker。 - 所有远端执行动作都先到节点代理,再由节点代理在本机执行。 +- `dashboard-edge` 应视为执行节点标准组件: + - 同机 Bot 节点可部署本机 edge + - 远端 Bot 节点部署远端 edge + - 一个节点一个 edge,不是一个 Bot 一个 edge +- `dashboard-edge` 的长期定位应是“核心适配宿主”: + - 第一阶段适配 nanobot + - 后续可扩展适配其它 Bot Core + - 对上统一提供 dashboard 可消费的执行协议 ## 3. 为什么选择方案 A @@ -211,6 +219,36 @@ nanobot 核心本身不需要感知节点、中心控制面或跨机调度。 ### 8.1 职责 +- 对中心端暴露统一的 Edge API +- 将中心端的 Bot 运行控制转换为本机 Docker / Host 操作 +- 代理本机 workspace 访问 +- 维护本机 Bot 的日志与 monitor 流 + +### 8.2 第一版最小 HTTP 契约 + +建议 `dashboard-edge` 第一版至少提供以下接口,全部由中心端 `HttpEdgeClient` 对接: + +- `POST /api/edge/bots/{bot_id}/start` +- `POST /api/edge/bots/{bot_id}/stop` +- `POST /api/edge/bots/{bot_id}/command` +- `POST /api/edge/bots/{bot_id}/monitor/ensure` +- `GET /api/edge/bots/{bot_id}/logs` +- `POST /api/edge/bots/{bot_id}/workspace/sync` +- `GET /api/edge/bots/{bot_id}/workspace/tree` +- `GET /api/edge/bots/{bot_id}/workspace/file` +- `PUT /api/edge/bots/{bot_id}/workspace/file/markdown` + +请求头建议: + +- `x-dashboard-edge-token` + +最小返回约定: + +- 控制类接口返回 `{ "status": "started|stopped|ok" }` +- 日志接口返回 `{ "bot_id": "...", "logs": [] }` +- monitor 保活返回 `{ "ensured": true }` +- workspace 接口尽量复用当前 dashboard 本地接口的返回结构,降低前端差异 + 远端节点代理 `dashboard-edge` 负责: - 本机 Bot 注册和枚举 @@ -222,6 +260,12 @@ nanobot 核心本身不需要感知节点、中心控制面或跨机调度。 - Dashboard Channel 配置自动注入 - 向中心汇报节点状态和 Bot 状态 +补充说明: + +- 长期看,`dashboard-edge` 不只是“远端代理” +- 对于同机部署的执行节点,也建议通过本机 edge 统一执行路径 +- 这样 central backend 不需要长期维护一套本机直连分支和一套远端代理分支 + ### 8.2 统一执行接口 节点代理内部也需要抽象执行器: @@ -240,6 +284,22 @@ nanobot 核心本身不需要感知节点、中心控制面或跨机调度。 - `write_workspace_file` - `get_resource_snapshot` +节点代理内部建议再区分两层: + +- `CoreAdapter` + - 负责适配不同 Bot 核心 +- `Executor` + - 负责 Docker / Host 执行 + +例如: + +- `NanobotCoreAdapter + DockerExecutor` +- `NanobotCoreAdapter + HostProcessExecutor` +- `LobsterCoreAdapter + DockerExecutor` +- `LobsterCoreAdapter + HostProcessExecutor` + +这样 edge 才能成为真正可扩展的“桥接层”,而不是只绑定当前 nanobot 实现。 + ### 8.3 HostProcessExecutor 推荐实现 首版建议使用“节点代理托管子进程”的方式,而不是一开始就深度接入系统服务管理器。 @@ -493,6 +553,28 @@ sequenceDiagram ## 15. 分阶段实施计划 +### 14.1 迁移 / 部署入口 + +Bot 的跨节点迁移、重建到新节点、以及运行时或镜像切换,不应放在 Bot 的基础信息编辑里,而应作为单独的高风险运维动作处理。 + +建议在平台管理区预留独立入口,进入后再由迁移向导逐步完成: + +- 选择源 Bot +- 选择目标节点 +- 校验目标节点能力 +- 同步 workspace、配置和运行元数据 +- 停止源节点运行实例 +- 在目标节点重建并启动 +- 回写中心端节点与 Bot 关联 + +设计原则: + +- 迁移动作必须显式确认 +- 迁移和部署入口应与日常基础编辑分离 +- 普通 Bot 编辑不允许修改节点、运行模式和基础镜像 +- 创建时可以指定目标节点和镜像,但创建后不再在基础信息里暴露这些字段 +- 远端 `dashboard-edge` 与中心端之间只通过迁移/部署协议协作,不通过临时手工字段拼接 + ### 15.1 第一阶段:跑通远端控制链路 目标: diff --git a/frontend/src/App.css b/frontend/src/App.css index bf97105..92c1fe2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1875,6 +1875,15 @@ body { border-color: rgba(97, 174, 255, 0.34); } +.platform-entry-card.is-static { + cursor: default; +} + +.platform-entry-card.is-static:hover { + transform: none; + border-color: rgba(97, 174, 255, 0.18); +} + .platform-entry-card strong { color: var(--title); } @@ -1884,6 +1893,338 @@ body { color: var(--muted); } +.platform-node-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.platform-node-toolbar { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.platform-node-card { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + padding: 18px; + border-radius: 18px; + border: 1px solid rgba(97, 174, 255, 0.2); + background: linear-gradient(180deg, rgba(14, 22, 38, 0.84), rgba(8, 12, 21, 0.92)); + color: var(--text); + text-align: left; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.platform-node-card:hover { + transform: translateY(-1px); + border-color: rgba(97, 174, 255, 0.38); + box-shadow: 0 16px 32px rgba(8, 25, 60, 0.12); +} + +.platform-node-card.is-disabled { + opacity: 0.8; +} + +.platform-node-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.platform-node-card-head-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.platform-node-card-head strong { + display: block; + font-size: 17px; + font-weight: 800; + color: var(--title); +} + +.platform-node-card-id { + margin-top: 4px; + font-size: 11px; + color: var(--muted); +} + +.platform-node-card-meta { + font-size: 12px; + color: var(--muted); +} + +.platform-node-card-url { + font-size: 11px; + color: var(--muted); + word-break: break-all; +} + +.platform-node-card-url-muted { + color: rgba(68, 87, 145, 0.72); +} + +.platform-node-card-stats { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 12px; + color: var(--muted); +} + +.platform-node-card-capabilities { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.platform-node-card-hint { + font-size: 12px; + color: var(--muted); +} + +.platform-node-card-last-seen { + font-size: 12px; + color: var(--muted); +} + +.platform-node-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 6px; + color: var(--title); +} + +.platform-node-card-link { + font-size: 13px; + font-weight: 700; +} + +.platform-node-editor { + width: min(760px, 94vw); + max-height: min(88vh, 920px); + overflow-y: auto; +} + +.platform-node-editor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.platform-node-editor-span-2 { + grid-column: span 2; +} + +.platform-node-editor .field { + min-width: 0; +} + +.platform-node-editor .field-checkbox { + justify-content: flex-end; +} + +.platform-node-native-panel { + margin-top: 12px; + padding: 12px; + border-radius: 14px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%); + display: grid; + gap: 10px; +} + +.platform-node-native-panel-title { + font-size: 13px; + font-weight: 700; + color: var(--title); +} + +.platform-node-native-panel-grid { + display: grid; + gap: 10px; +} + +.check-row { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); +} + +.platform-node-test-result { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--panel) 74%, transparent); + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + max-height: 240px; + overflow: auto; +} + +.platform-node-test-result.is-ok { + border-color: color-mix(in oklab, var(--ok) 42%, var(--line) 58%); + background: color-mix(in oklab, var(--ok) 10%, var(--panel) 90%); +} + +.platform-node-test-result.is-error { + border-color: color-mix(in oklab, var(--err) 42%, var(--line) 58%); + background: color-mix(in oklab, var(--err) 8%, var(--panel) 92%); +} + +.platform-node-test-result-head, +.platform-node-test-result-meta, +.platform-node-editor-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.platform-node-test-kv { + display: grid; + gap: 6px; +} + +.platform-node-test-code { + display: block; + max-width: 100%; + overflow-x: auto; + white-space: nowrap; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--panel) 90%, transparent); +} + +.platform-node-direct-note { + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--panel) 82%, transparent); + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.platform-node-current-target { + padding: 10px 12px; + border-radius: 14px; + border: 1px dashed color-mix(in oklab, var(--brand) 34%, var(--line) 66%); + background: color-mix(in oklab, var(--brand) 8%, var(--panel) 92%); + color: var(--title); + font-size: 12px; +} + +.node-workspace-page { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.node-workspace-shell { + display: flex; + flex-direction: column; + gap: 18px; + flex: 0 0 auto; + min-height: 0; +} + +.node-workspace-summary-grid { + grid-template-columns: minmax(260px, 0.96fr) minmax(220px, 0.82fr) minmax(0, 2.22fr); +} + +.node-workspace-summary-grid .platform-resource-card { + grid-column: auto; +} + +.node-workspace-summary-card { + min-width: 0; +} + +.node-workspace-summary-value { + font-size: 22px; + font-weight: 800; + color: var(--title); + line-height: 1.2; + word-break: break-word; +} + +.node-workspace-summary-id { + margin-top: -2px; + font-size: 12px; + color: var(--muted); +} + +.node-workspace-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 2px; +} + +.node-workspace-resource-card { + min-width: 0; +} + +.node-workspace-content-shell { + display: flex; + flex: 1 1 auto; + min-height: 0; + margin-top: 24px; +} + +.node-workspace-content-shell > * { + flex: 1 1 auto; + min-height: 0; +} + +.platform-home-shell { + display: flex; + flex-direction: column; + gap: 18px; + min-height: 0; +} + +.platform-home-summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.platform-home-body { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.65fr); + gap: 18px; + align-items: start; +} + +.platform-home-node-section, +.platform-home-management-section { + min-width: 0; +} + +.platform-home-node-section .platform-node-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.platform-home-management-section .platform-entry-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .platform-settings-shell { max-width: min(1400px, 96vw); } @@ -2468,6 +2809,34 @@ body { color: #173057; } +.app-shell[data-theme='light'] .platform-node-card { + border-color: #b7c7e6; + background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%); + color: #173057; +} + +.app-shell[data-theme='light'] .platform-node-card:hover { + border-color: #8fb4ef; + box-shadow: 0 14px 28px rgba(86, 118, 176, 0.12); +} + +.app-shell[data-theme='light'] .platform-node-card-head strong, +.app-shell[data-theme='light'] .platform-node-card-foot, +.app-shell[data-theme='light'] .platform-node-card-link, +.app-shell[data-theme='light'] .node-workspace-summary-value { + color: #173057; +} + +.app-shell[data-theme='light'] .platform-node-card-id, +.app-shell[data-theme='light'] .platform-node-card-meta, +.app-shell[data-theme='light'] .platform-node-card-url, +.app-shell[data-theme='light'] .platform-node-card-stats, +.app-shell[data-theme='light'] .platform-node-card-last-seen, +.app-shell[data-theme='light'] .platform-node-card-hint, +.app-shell[data-theme='light'] .node-workspace-summary-id { + color: #49648f; +} + .app-shell[data-theme='light'] .platform-entry-card strong { color: #173057; } @@ -2609,6 +2978,15 @@ body { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .platform-home-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .node-workspace-summary-grid, + .platform-home-body { + grid-template-columns: minmax(0, 1fr); + } + .platform-resource-card { grid-column: span 3; } @@ -2626,6 +3004,7 @@ body { } .platform-grid, + .platform-home-body, .platform-main-grid, .platform-monitor-grid, .platform-entry-grid, @@ -2633,6 +3012,11 @@ body { grid-template-columns: 1fr; } + .platform-home-node-section .platform-node-grid, + .platform-home-management-section .platform-entry-grid { + grid-template-columns: 1fr; + } + .platform-resource-card { grid-column: auto; } @@ -2687,6 +3071,14 @@ body { .wizard-shell { min-height: 640px; } + + .platform-node-editor-grid { + grid-template-columns: 1fr; + } + + .platform-node-editor-span-2 { + grid-column: span 1; + } } @media (max-width: 980px) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8504eb..5ab5db8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,9 +10,15 @@ import { appEn } from './i18n/app.en'; import { LucentTooltip } from './components/lucent/LucentTooltip'; import { PasswordInput } from './components/PasswordInput'; import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess'; -import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess'; +import { + PANEL_AUTH_REQUIRED_EVENT, + clearPanelAccessPassword, + getPanelAccessPassword, + setPanelAccessPassword, +} from './utils/panelAccess'; import { BotHomePage } from './modules/bot-home/BotHomePage'; -import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage'; +import { NodeHomePage } from './modules/platform/NodeHomePage'; +import { NodeWorkspacePage } from './modules/platform/NodeWorkspacePage'; import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal'; import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute'; import './App.css'; @@ -41,6 +47,7 @@ function AuthenticatedApp() { : { show: 'Show password', hide: 'Hide password' }; const forcedBotId = route.kind === 'bot' ? route.botId : ''; + const forcedNodeId = route.kind === 'dashboard-node' ? route.nodeId : ''; useBotsSync(forcedBotId || undefined); useEffect(() => { @@ -66,8 +73,10 @@ function AuthenticatedApp() { const headerTitle = route.kind === 'bot' ? (forcedBot?.name || defaultLoadingPage.title) + : route.kind === 'dashboard-node' + ? `${t.nodeWorkspace} · ${forcedNodeId || 'local'}` : route.kind === 'dashboard-skills' - ? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace') + ? t.skillMarketplace : t.title; useEffect(() => { @@ -76,12 +85,16 @@ function AuthenticatedApp() { return; } if (route.kind === 'dashboard-skills') { - document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`; + document.title = `${t.title} - ${t.skillMarketplace}`; + return; + } + if (route.kind === 'dashboard-node') { + document.title = `${t.title} - ${t.nodeWorkspace} - ${forcedNodeId || 'local'}`; return; } const botName = String(forcedBot?.name || '').trim(); document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`; - }, [forcedBot?.name, forcedBotId, locale, route.kind, t.title]); + }, [forcedBot?.name, forcedBotId, forcedNodeId, route.kind, t.nodeWorkspace, t.skillMarketplace, t.title]); useEffect(() => { setSingleBotUnlocked(false); @@ -158,13 +171,17 @@ function AuthenticatedApp() {

{headerTitle}

{route.kind === 'dashboard-skills' ? ( + ) : route.kind === 'dashboard-node' ? ( + ) : (
{route.kind === 'dashboard' - ? (locale === 'zh' ? '平台总览' : 'Platform overview') - : (locale === 'zh' ? 'Bot 首页' : 'Bot Home')} + ? t.platformHome + : t.botHome}
)} {isCompactShell ? ( @@ -221,7 +238,9 @@ function AuthenticatedApp() {
{route.kind === 'dashboard' ? ( - + + ) : route.kind === 'dashboard-node' ? ( + ) : route.kind === 'dashboard-skills' ? ( ) : ( @@ -316,8 +335,13 @@ function PanelLoginGate({ children }: { children: ReactElement }) { } } catch { if (!alive) return; - setRequired(false); - setAuthenticated(true); + setRequired(true); + setAuthenticated(false); + setError( + locale === 'zh' + ? '无法确认面板访问状态,请重新输入面板密码。若仍失败,请检查 Dashboard Backend 是否已重启并应用最新配置。' + : 'Unable to verify panel access. Enter the panel password again. If it still fails, restart the Dashboard backend and ensure the latest config is loaded.', + ); setChecking(false); } }; @@ -327,6 +351,23 @@ function PanelLoginGate({ children }: { children: ReactElement }) { }; }, [bypassPanelGate, locale]); + useEffect(() => { + if (typeof window === 'undefined' || bypassPanelGate) return; + const onPanelAuthRequired = (event: Event) => { + const detail = String((event as CustomEvent)?.detail || '').trim(); + setRequired(true); + setAuthenticated(false); + setChecking(false); + setSubmitting(false); + setPassword(''); + setError( + detail || (locale === 'zh' ? '面板访问密码已失效,请重新输入。' : 'Panel access password expired. Please sign in again.'), + ); + }; + window.addEventListener(PANEL_AUTH_REQUIRED_EVENT, onPanelAuthRequired as EventListener); + return () => window.removeEventListener(PANEL_AUTH_REQUIRED_EVENT, onPanelAuthRequired as EventListener); + }, [bypassPanelGate, locale]); + const onSubmit = async () => { const next = String(password || '').trim(); if (!next) { diff --git a/frontend/src/i18n/app.en.ts b/frontend/src/i18n/app.en.ts index f8a1686..58aedde 100644 --- a/frontend/src/i18n/app.en.ts +++ b/frontend/src/i18n/app.en.ts @@ -1,5 +1,9 @@ export const appEn = { title: 'Nanobot Control Plane', + platformHome: 'Node Home', + skillMarketplace: 'Skill Marketplace', + nodeWorkspace: 'Node Workspace', + botHome: 'Bot Home', theme: 'Theme', language: 'Language', dark: 'Dark', diff --git a/frontend/src/i18n/app.zh-cn.ts b/frontend/src/i18n/app.zh-cn.ts index 0f98c3c..f618bde 100644 --- a/frontend/src/i18n/app.zh-cn.ts +++ b/frontend/src/i18n/app.zh-cn.ts @@ -1,5 +1,9 @@ export const appZhCn = { title: 'Nanobot 管理面板', + platformHome: '节点首页', + skillMarketplace: '技能市场管理', + nodeWorkspace: '节点工作台', + botHome: 'Bot 首页', theme: '主题', language: '语言', dark: '深色', diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index 075eafc..4b0517e 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -66,6 +66,9 @@ export const dashboardEn = { deleteBotDone: 'Bot and workspace deleted.', deleteFail: 'Delete failed.', titleBots: 'Bots', + nodeLabel: 'Node', + nodeUnknown: 'Unnamed node', + nodeGroupCount: (count: number) => `${count} bot${count === 1 ? '' : 's'}`, botSearchPlaceholder: 'Search by bot name or ID', botSearchNoResult: 'No matching bots.', workspaceSearchPlaceholder: 'Search by file name or path', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 8c2b4a5..98f4b87 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -66,6 +66,9 @@ export const dashboardZhCn = { deleteBotDone: 'Bot 与 workspace 已删除。', deleteFail: '删除失败。', titleBots: 'Bot 列表', + nodeLabel: '节点', + nodeUnknown: '未命名节点', + nodeGroupCount: (count: number) => `${count} 个 Bot`, botSearchPlaceholder: '按 Bot 名称或 ID 搜索', botSearchNoResult: '没有匹配的 Bot。', workspaceSearchPlaceholder: '按文件名或路径搜索', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 15c307e..85d210f 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -205,6 +205,55 @@ font-weight: 700; } +.ops-bot-group { + margin-bottom: 14px; +} + +.ops-bot-group:last-child { + margin-bottom: 0; +} + +.ops-bot-group-head { + margin: 4px 2px 10px; + padding: 0 2px; +} + +.ops-bot-group-title-wrap { + display: grid; + gap: 4px; +} + +.ops-bot-group-title { + font-size: 13px; + font-weight: 900; + letter-spacing: 0.02em; + color: var(--title); +} + +.ops-bot-group-subtitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--subtitle); + font-weight: 700; +} + +.ops-bot-group-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--brand) 32%, var(--line) 68%); + background: color-mix(in oklab, var(--brand-soft) 24%, var(--panel-soft) 76%); + color: var(--title); +} + +.ops-bot-group-sep { + opacity: 0.55; +} + .ops-bot-card { position: relative; border: 1px solid var(--line); @@ -381,6 +430,31 @@ gap: 8px; } +.ops-runtime-kind-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 64px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line) 84%, transparent); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.ops-runtime-kind-badge.is-docker { + color: color-mix(in oklab, #0f4e8c 76%, var(--text) 24%); + background: color-mix(in oklab, #9ed4ff 34%, var(--panel) 66%); + border-color: color-mix(in oklab, #64a8e8 42%, var(--line) 58%); +} + +.ops-runtime-kind-badge.is-native { + color: color-mix(in oklab, #21643a 78%, var(--text) 22%); + background: color-mix(in oklab, #bfe8b6 34%, var(--panel) 66%); + border-color: color-mix(in oklab, #86cd95 42%, var(--line) 58%); +} + .ops-bot-name-row { display: inline-flex; align-items: center; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 226c1ac..15d506a 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -7,7 +7,7 @@ import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { APP_ENDPOINTS } from '../../config/env'; import { useAppStore } from '../../store/appStore'; -import type { ChatMessage } from '../../types/bot'; +import type { BotState, ChatMessage } from '../../types/bot'; import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser'; import nanobotLogo from '../../assets/nanobot-logo.png'; import './BotDashboardModule.css'; @@ -35,9 +35,17 @@ interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; onOpenImageFactory?: () => void; forcedBotId?: string; + forcedNodeId?: string; compactMode?: boolean; } +interface NodeBotGroup { + key: string; + label: string; + nodeId: string; + bots: BotState[]; +} + type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type WorkspaceNodeType = 'dir' | 'file'; type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack' | 'email'; @@ -212,17 +220,6 @@ interface TopicFeedStatsResponse { latest_item_id?: number | null; } -interface NanobotImage { - tag: string; - status: string; -} - -interface BaseImageOption { - tag: string; - label: string; - disabled: boolean; -} - interface WorkspaceSkillOption { id: string; name: string; @@ -414,6 +411,13 @@ const providerPresets: Record([]); const [controlCommandByBot, setControlCommandByBot] = useState>({}); const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ sendProgress: false, @@ -1242,12 +1246,6 @@ export function BotDashboardModule({ const [expandedUserByKey, setExpandedUserByKey] = useState>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); - const [showTemplateModal, setShowTemplateModal] = useState(false); - const [templateTab, setTemplateTab] = useState<'agent' | 'topic'>('agent'); - const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); - const [isSavingTemplates, setIsSavingTemplates] = useState(false); - const [templateAgentText, setTemplateAgentText] = useState(''); - const [templateTopicText, setTemplateTopicText] = useState(''); const [controlCommandPanelOpen, setControlCommandPanelOpen] = useState(false); const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null); const botSearchInputName = useMemo( @@ -1624,7 +1622,13 @@ export function BotDashboardModule({ const bots = useMemo( () => - Object.values(activeBots).sort((a, b) => { + Object.values(activeBots) + .filter((bot) => { + const expectedNodeId = String(forcedNodeId || '').trim().toLowerCase(); + if (!expectedNodeId) return true; + return String(bot.node_id || 'local').trim().toLowerCase() === expectedNodeId; + }) + .sort((a, b) => { const aId = String(a.id || '').trim(); const bId = String(b.id || '').trim(); const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER; @@ -1632,7 +1636,7 @@ export function BotDashboardModule({ if (aOrder !== bOrder) return aOrder - bOrder; return aId.localeCompare(bId); }), - [activeBots], + [activeBots, forcedNodeId], ); const hasForcedBot = Boolean(String(forcedBotId || '').trim()); const compactListFirstMode = compactMode && !hasForcedBot; @@ -1645,7 +1649,14 @@ export function BotDashboardModule({ return bots.filter((bot) => { const id = String(bot.id || '').toLowerCase(); const name = String(bot.name || '').toLowerCase(); - return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery); + const nodeId = String(bot.node_id || '').toLowerCase(); + const nodeName = String(bot.node_display_name || '').toLowerCase(); + return ( + id.includes(normalizedBotListQuery) || + name.includes(normalizedBotListQuery) || + nodeId.includes(normalizedBotListQuery) || + nodeName.includes(normalizedBotListQuery) + ); }); }, [bots, normalizedBotListQuery]); const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize)); @@ -1654,6 +1665,32 @@ export function BotDashboardModule({ const start = (page - 1) * botListPageSize; return filteredBots.slice(start, start + botListPageSize); }, [filteredBots, botListPage, botListTotalPages, botListPageSize]); + const pagedBotGroups = useMemo(() => { + const unknownNodeLabel = locale === 'zh' ? '未命名节点' : 'Unnamed node'; + const groups = new Map(); + pagedBots.forEach((bot) => { + const nodeId = String(bot.node_id || 'local').trim() || 'local'; + const label = String(bot.node_display_name || '').trim() || nodeId || unknownNodeLabel; + const key = nodeId.toLowerCase(); + const existing = groups.get(key); + if (existing) { + existing.bots.push(bot); + return; + } + groups.set(key, { + key, + label, + nodeId, + bots: [bot], + }); + }); + return Array.from(groups.values()).sort((a, b) => { + if (a.key === b.key) return 0; + if (a.key === 'local') return -1; + if (b.key === 'local') return 1; + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); + }); + }, [locale, pagedBots]); const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]); const messages = selectedBot?.messages || []; @@ -1682,33 +1719,6 @@ export function BotDashboardModule({ return 'ready'; }, [activeTopicOptions, topics]); const lc = isZh ? channelsZhCn : channelsEn; - const baseImageOptions = useMemo(() => { - const imagesByTag = new Map(); - availableImages.forEach((img) => { - const tag = String(img.tag || '').trim(); - if (!tag || imagesByTag.has(tag)) return; - imagesByTag.set(tag, img); - }); - const options = Array.from(imagesByTag.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([tag, img]) => { - const status = String(img.status || '').toUpperCase() || 'UNKNOWN'; - return { - tag, - label: `${tag} · ${status}`, - disabled: status !== 'READY', - }; - }); - const currentTag = String(editForm.image_tag || '').trim(); - if (currentTag && !options.some((opt) => opt.tag === currentTag)) { - options.unshift({ - tag: currentTag, - label: isZh ? `${currentTag} · 未登记(只读)` : `${currentTag} · unregistered (read-only)`, - disabled: true, - }); - } - return options; - }, [availableImages, editForm.image_tag, isZh]); const runtimeMoreLabel = isZh ? '更多' : 'More'; const effectiveTopicPresetTemplates = useMemo( () => (topicPresetTemplates.length > 0 ? topicPresetTemplates : DEFAULT_TOPIC_PRESET_TEMPLATES), @@ -1720,28 +1730,6 @@ export function BotDashboardModule({ if (!source) return t.topicPresetBlank; return resolvePresetText(source.name, isZh ? 'zh-cn' : 'en') || source.topic_key || source.id; }, [effectiveTopicPresetTemplates, isZh, newTopicSource, t.topicPresetBlank]); - const templateAgentCount = useMemo(() => { - try { - const parsed = JSON.parse(templateAgentText || "{}"); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 5; - const row = parsed as Record; - return ["agents_md", "soul_md", "user_md", "tools_md", "identity_md"].filter((k) => - Object.prototype.hasOwnProperty.call(row, k), - ).length || 5; - } catch { - return 5; - } - }, [templateAgentText]); - const templateTopicCount = useMemo(() => { - try { - const parsed = JSON.parse(templateTopicText || '{"presets":[]}') as Record; - const rows = parsed?.presets; - if (Array.isArray(rows)) return rows.length; - return effectiveTopicPresetTemplates.length; - } catch { - return effectiveTopicPresetTemplates.length; - } - }, [templateTopicText, effectiveTopicPresetTemplates.length]); const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false); const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0; @@ -2385,28 +2373,17 @@ export function BotDashboardModule({ }); }, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]); - const loadImageOptions = async () => { - const [imagesRes] = await Promise.allSettled([axios.get(`${APP_ENDPOINTS.apiBase}/images`)]); - if (imagesRes.status === 'fulfilled') { - setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []); - } else { - setAvailableImages([]); - } - }; - const refresh = async () => { const forced = String(forcedBotId || '').trim(); if (forced) { const targetId = String(selectedBotId || forced).trim() || forced; const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`); setBots(botRes.data ? [botRes.data] : []); - await loadImageOptions(); return; } const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(botsRes.data); - await loadImageOptions(); }; const ensureSelectedBotDetail = useCallback(async () => { @@ -2441,17 +2418,6 @@ export function BotDashboardModule({ void loadResourceSnapshot(botId); }; - useEffect(() => { - void loadImageOptions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!showBaseModal) return; - void loadImageOptions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showBaseModal]); - useEffect(() => { if (!showResourceModal || !resourceBotId) return; let stopped = false; @@ -3811,71 +3777,6 @@ export function BotDashboardModule({ return null; }; - const openTemplateManager = async () => { - setBotListMenuOpen(false); - setIsLoadingTemplates(true); - try { - const res = await axios.get(`${APP_ENDPOINTS.apiBase}/system/templates`); - const agentRaw = res.data?.agent_md_templates; - const topicRaw = res.data?.topic_presets; - setTemplateAgentText(JSON.stringify(agentRaw && typeof agentRaw === 'object' ? agentRaw : {}, null, 2)); - setTemplateTopicText(JSON.stringify(topicRaw && typeof topicRaw === 'object' ? topicRaw : { presets: [] }, null, 2)); - setTemplateTab('agent'); - setShowTemplateModal(true); - } catch { - notify(t.templateLoadFail, { tone: 'error' }); - } finally { - setIsLoadingTemplates(false); - } - }; - - const saveTemplateManager = async (scope: 'agent' | 'topic') => { - let payload: Record; - try { - if (scope === 'agent') { - const parsedAgent = JSON.parse(templateAgentText || '{}'); - if (!parsedAgent || typeof parsedAgent !== 'object' || Array.isArray(parsedAgent)) { - throw new Error(t.templateAgentInvalid); - } - const agentObject = parsedAgent as Record; - payload = { - agent_md_templates: { - agents_md: String(agentObject.agents_md || ''), - soul_md: String(agentObject.soul_md || ''), - user_md: String(agentObject.user_md || ''), - tools_md: String(agentObject.tools_md || ''), - identity_md: String(agentObject.identity_md || ''), - }, - }; - } else { - const parsedTopic = JSON.parse(templateTopicText || '{"presets":[]}'); - if (!parsedTopic || typeof parsedTopic !== 'object' || Array.isArray(parsedTopic)) { - throw new Error(t.templateTopicInvalid); - } - payload = { - topic_presets: parsedTopic, - }; - } - } catch (error: any) { - notify(error?.message || t.templateParseFail, { tone: 'error' }); - return; - } - - setIsSavingTemplates(true); - try { - await axios.put(`${APP_ENDPOINTS.apiBase}/system/templates`, payload); - notify(t.templateSaved, { tone: 'success' }); - if (scope === 'topic') { - const defaults = await axios.get(`${APP_ENDPOINTS.apiBase}/system/defaults`); - setTopicPresetTemplates(parseTopicPresets(defaults.data?.topic_presets)); - } - } catch { - notify(t.templateSaveFail, { tone: 'error' }); - } finally { - setIsSavingTemplates(false); - } - }; - const batchStartBots = async () => { if (isBatchOperating) return; const candidates = bots.filter((bot) => bot.enabled !== false && String(bot.docker_status || '').toUpperCase() !== 'RUNNING'); @@ -5017,12 +4918,7 @@ export function BotDashboardModule({ if (mode === 'base') { payload.name = editForm.name; payload.access_password = editForm.access_password; - payload.image_tag = editForm.image_tag; payload.system_timezone = editForm.system_timezone.trim() || defaultSystemTimezone; - const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag); - if (selectedImageOption?.disabled) { - throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.'); - } const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores)); const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb)); const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb)); @@ -5235,6 +5131,16 @@ export function BotDashboardModule({ : `${t.titleBots} (${bots.length})`}
+ {onOpenImageFactory ? ( + + + + ) : null} {botListMenuOpen ? (
- -
) : null} {botListPageSizeReady - ? pagedBots.map((bot) => { + ? pagedBotGroups.map((group) => { + const modeSet = Array.from( + new Set( + group.bots.map((bot) => { + const transport = String(bot.transport_kind || '').trim() || 'direct'; + const runtime = String(bot.runtime_kind || '').trim() || 'docker'; + return `${transport}/${runtime}`; + }), + ), + ); + return ( +
+
+
+
{group.label}
+
+ {t.nodeLabel} + {group.nodeId} + + {t.nodeGroupCount(group.bots.length)} + {modeSet.length > 0 ? ( + <> + + {modeSet.join(', ')} + + ) : null} +
+
+
+ {group.bots.map((bot) => { const selected = selectedBotId === bot.id; const controlState = controlStateByBot[bot.id]; const isOperating = operatingBotId === bot.id; @@ -5360,6 +5272,7 @@ export function BotDashboardModule({ const isEnabling = controlState === 'enabling'; const isDisabling = controlState === 'disabling'; const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING'; + const runtimeKind = String(bot.runtime_kind || '').trim().toLowerCase() || 'docker'; return (
{bot.name}
{ - e.stopPropagation(); - const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`; - window.open(target, '_blank', 'noopener,noreferrer'); - }} - tooltip={isZh ? '新页面打开' : 'Open in new page'} - aria-label={isZh ? '新页面打开' : 'Open in new page'} - > - - + onClick={(e) => { + e.stopPropagation(); + const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`; + window.open(target, '_blank', 'noopener,noreferrer'); + }} + tooltip={isZh ? '新页面打开' : 'Open in new page'} + aria-label={isZh ? '新页面打开' : 'Open in new page'} + > + +
{bot.id}
@@ -5398,10 +5311,20 @@ export function BotDashboardModule({ {!isEnabled ? ( {t.disabled} ) : null} + + {runtimeKind === 'native' ? 'NATIVE' : 'DOCKER'} + {bot.docker_status}
{t.image}: {bot.image_tag || '-'}
+
+ + {(String(bot.transport_kind || '').trim() || 'direct')}/{(String(bot.runtime_kind || '').trim() || 'docker')} + + · + {String(bot.core_adapter || '').trim() || 'nanobot'} +
+ ); + })} + ); }) : null} @@ -6330,18 +6256,6 @@ export function BotDashboardModule({ toggleLabels={passwordToggleLabels} /> - - setEditForm((p) => ({ ...p, image_tag: e.target.value }))} - > - {baseImageOptions.map((img) => ( - - ))} - - openrouter + @@ -7667,73 +7582,6 @@ export function BotDashboardModule({ )} - {showTemplateModal && ( -
setShowTemplateModal(false)}> -
e.stopPropagation()}> -
-
-

{t.templateManagerTitle}

-
-
- setShowTemplateModal(false)} tooltip={t.close} aria-label={t.close}> - - -
-
- -
- - -
- -
- {templateTab === 'agent' ? ( -
-