From 413a7d6efbff95aa85d248a817aec12d6aebb848 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Tue, 3 Mar 2026 14:09:11 +0800 Subject: [PATCH] v 0.1.3 --- .env.prod.example | 3 + backend/.env.example | 3 + backend/core/docker_manager.py | 366 +++++++++++- backend/core/settings.py | 12 + backend/main.py | 495 +++++++++++++--- design/architecture.md | 9 +- design/dashboard-nanobot.md | 17 +- design/database.md | 109 ++-- docker-compose.prod.yml | 1 + frontend/docker/nginx.conf | 4 + frontend/src/i18n/dashboard.en.ts | 7 +- frontend/src/i18n/dashboard.zh-cn.ts | 7 +- .../modules/dashboard/BotDashboardModule.css | 6 + .../modules/dashboard/BotDashboardModule.tsx | 531 +++++++++++++++++- .../modules/onboarding/BotWizardModule.tsx | 114 +++- frontend/src/types/bot.ts | 3 + 16 files changed, 1488 insertions(+), 199 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index f9848ba..186130c 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -29,3 +29,6 @@ NPM_REGISTRY=https://registry.npmmirror.com # DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard DATABASE_URL= + +# Max upload size for backend validation (MB) +UPLOAD_MAX_MB=100 diff --git a/backend/.env.example b/backend/.env.example index 22de751..8cf25a9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,6 +13,9 @@ BOTS_WORKSPACE_ROOT=../workspace/bots # Show SQL statements in backend logs (debug only). DATABASE_ECHO=true +# Max upload size for backend validation (MB) +UPLOAD_MAX_MB=100 + # Local backend server options (for `python3 main.py`) APP_HOST=0.0.0.0 APP_PORT=8000 diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index c3e8419..8584674 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -3,7 +3,8 @@ import re import threading import time import codecs -from typing import Callable, Dict, List, Optional +import base64 +from typing import Any, Callable, Dict, List, Optional, Tuple import json import docker @@ -22,6 +23,36 @@ class BotDockerManager: self.host_data_root = host_data_root self.base_image = base_image self.active_monitors = {} + self._last_delivery_error: Dict[str, str] = {} + + @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: @@ -62,6 +93,9 @@ class BotDockerManager: bot_id: str, image_tag: Optional[str] = None, env_vars: Optional[Dict[str, 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: @@ -76,6 +110,23 @@ class BotDockerManager: bot_workspace = os.path.join(self.host_data_root, bot_id, ".nanobot") container_name = f"worker_{bot_id}" os.makedirs(bot_workspace, exist_ok=True) + cpu, memory, storage = self._normalize_resource_limits(cpu_cores, memory_mb, storage_gb) + base_kwargs = { + "image": image, + "name": container_name, + "detach": True, + "stdin_open": True, + "tty": True, + "environment": env_vars or {}, + "volumes": { + bot_workspace: {"bind": "/root/.nanobot", "mode": "rw"}, + }, + "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: @@ -89,20 +140,19 @@ class BotDockerManager: except docker.errors.NotFound: pass - container = self.client.containers.run( - image=image, - name=container_name, - detach=True, - stdin_open=True, - tty=True, - environment=env_vars or {}, - volumes={ - bot_workspace: {"bind": "/root/.nanobot", "mode": "rw"}, - }, - mem_limit="1g", - cpu_quota=100000, - network_mode="bridge", - ) + container = None + if storage > 0: + try: + container = self.client.containers.run( + storage_opt={"size": f"{storage}G"}, + **base_kwargs, + ) + except Exception as e: + # Some Docker engines (e.g. Desktop/overlay2) may not support size storage option. + print(f"[DockerManager] storage limit not applied for {bot_id}: {e}") + container = self.client.containers.run(**base_kwargs) + else: + container = self.client.containers.run(**base_kwargs) if on_state_change: monitor_thread = threading.Thread( @@ -164,44 +214,304 @@ class BotDockerManager: def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: """Send a command to dashboard channel with robust container-local delivery.""" 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) # Primary path on Docker Desktop/Mac: execute curl inside container namespace. 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)) # Secondary path for environments where host can reach container IP. - return self._send_command_via_host_http(bot_id, command, media_paths) + 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_bot_status(self, bot_id: str) -> str: + """Return normalized runtime status from Docker: RUNNING or STOPPED.""" + 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 docker.errors.NotFound: + 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: + # Older docker SDK versions do not support `size` kwarg. + 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 as e: + print(f"[DockerManager] get_bot_resource_snapshot failed for {bot_id}: {e}") + return snapshot 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 payload_json = json.dumps({"message": command, "media": media or []}, ensure_ascii=False) - cmd = ( - "curl -sS --fail --max-time 6 " - "-X POST -H 'Content-Type: application/json' " - "-d \"$DASHBOARD_PAYLOAD\" http://127.0.0.1:9000/chat" + + # Try direct curl first (no shell dependency). + result = container.exec_run( + [ + "curl", + "-sS", + "--fail", + "--max-time", + "6", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + payload_json, + "http://127.0.0.1:9000/chat", + ] ) - result = container.exec_run(["/bin/sh", "-c", cmd], environment={"DASHBOARD_PAYLOAD": payload_json}) output = result.output.decode("utf-8", errors="ignore") if isinstance(result.output, (bytes, bytearray)) else str(result.output) if result.exit_code != 0: - print(f"[DockerManager] exec curl failed for {bot_id}: exit={result.exit_code}, out={output[:300]}") + reason = f"exec curl failed: exit={result.exit_code}, out={output[:300]}" + print(f"[DockerManager] {reason}") + self._last_delivery_error[bot_id] = reason + # Fallback inside container without curl/shell. + 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('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" + ) + py_bins = ["python3", "python"] + for py_bin in py_bins: + py_result = container.exec_run( + [py_bin, "-c", py_script], + environment={"DASHBOARD_PAYLOAD_B64": payload_b64}, + ) + 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: + py_reason = f"exec {py_bin} fallback failed: exit={py_result.exit_code}, out={py_output[:300]}" + print(f"[DockerManager] {py_reason}") + self._last_delivery_error[bot_id] = py_reason + continue + if py_output.strip(): + try: + parsed = json.loads(py_output) + if str(parsed.get("status", "")).lower() != "ok": + py_reason = f"exec {py_bin} fallback non-ok response: {py_output[:300]}" + print(f"[DockerManager] {py_reason}") + self._last_delivery_error[bot_id] = py_reason + continue + except Exception: + pass + return True return False if output.strip(): try: parsed = json.loads(output) if str(parsed.get("status", "")).lower() != "ok": - print(f"[DockerManager] exec curl non-ok response for {bot_id}: {output[:300]}") + reason = f"exec curl non-ok response: {output[:300]}" + print(f"[DockerManager] {reason}") + self._last_delivery_error[bot_id] = reason return False except Exception: # Non-JSON but zero exit still treated as success. pass return True except Exception as e: - print(f"[DockerManager] exec curl exception for {bot_id}: {e}") + reason = f"exec curl exception: {e}" + print(f"[DockerManager] {reason}") + self._last_delivery_error[bot_id] = reason return False def _send_command_via_host_http(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: @@ -218,10 +528,14 @@ class BotDockerManager: resp = client.post(target_url, json=payload) if resp.status_code == 200: return True - print(f"[DockerManager] host HTTP failed: {resp.status_code} - {resp.text[:300]}") + reason = f"host HTTP failed: {resp.status_code} - {resp.text[:300]}" + print(f"[DockerManager] {reason}") + self._last_delivery_error[bot_id] = reason return False except Exception as e: - print(f"[DockerManager] host HTTP exception: {e}") + reason = f"host HTTP exception: {e}" + print(f"[DockerManager] {reason}") + self._last_delivery_error[bot_id] = reason return False def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]: diff --git a/backend/core/settings.py b/backend/core/settings.py index a807900..254f81f 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -27,6 +27,17 @@ def _env_bool(name: str, default: bool) -> bool: return str(raw).strip().lower() in {"1", "true", "yes", "on"} +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_dir_path(path_value: str) -> str: raw = str(path_value or "").strip() if not raw: @@ -99,6 +110,7 @@ DATABASE_URL: Final[str] = _normalize_database_url( DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL) DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) +UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048) DEFAULT_AGENTS_MD: Final[str] = _env_text( "DEFAULT_AGENTS_MD", diff --git a/backend/main.py b/backend/main.py index f0dc256..f4a5160 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,4 @@ import asyncio -import io import json import mimetypes import os @@ -32,6 +31,7 @@ from core.settings import ( DEFAULT_TOOLS_MD, DEFAULT_USER_MD, PROJECT_ROOT, + UPLOAD_MAX_MB, ) from models.bot import BotInstance, BotMessage, NanobotImage @@ -81,6 +81,9 @@ class BotCreateRequest(BaseModel): temperature: float = 0.2 top_p: float = 1.0 max_tokens: int = 8192 + cpu_cores: float = 1.0 + memory_mb: int = 1024 + storage_gb: int = 10 soul_md: Optional[str] = None agents_md: Optional[str] = None user_md: Optional[str] = None @@ -104,6 +107,9 @@ class BotUpdateRequest(BaseModel): temperature: Optional[float] = None top_p: Optional[float] = None max_tokens: Optional[int] = None + cpu_cores: Optional[float] = None + memory_mb: Optional[int] = None + storage_gb: Optional[int] = None soul_md: Optional[str] = None agents_md: Optional[str] = None user_md: Optional[str] = None @@ -276,6 +282,8 @@ async def on_startup(): print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}") init_database() with Session(engine) as session: + for bot in session.exec(select(BotInstance)).all(): + _migrate_bot_resources_store(bot.id) 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) @@ -303,7 +311,10 @@ def get_system_defaults(): "user_md": DEFAULT_USER_MD, "tools_md": DEFAULT_TOOLS_MD, "identity_md": DEFAULT_IDENTITY_MD, - } + }, + "limits": { + "upload_max_mb": UPLOAD_MAX_MB, + }, } @@ -342,6 +353,84 @@ def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None: os.replace(tmp, path) +def _resources_json_path(bot_id: str) -> str: + return os.path.join(_bot_data_root(bot_id), "resources.json") + + +def _write_bot_resources(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> None: + normalized = _normalize_resource_limits(cpu_cores, memory_mb, storage_gb) + payload = { + "cpuCores": normalized["cpu_cores"], + "memoryMB": normalized["memory_mb"], + "storageGB": normalized["storage_gb"], + } + path = _resources_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(payload, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) + + +def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + cpu_raw: Any = None + memory_raw: Any = None + storage_raw: Any = None + + path = _resources_json_path(bot_id) + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + 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")) + except Exception: + pass + + # Backward compatibility: read old runtime.resources only if new file is missing/incomplete. + if cpu_raw is None or memory_raw is None or storage_raw is None: + cfg = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id) + runtime_cfg = cfg.get("runtime") + if isinstance(runtime_cfg, dict): + resources_raw = runtime_cfg.get("resources") + if isinstance(resources_raw, dict): + if cpu_raw is None: + cpu_raw = resources_raw.get("cpuCores", resources_raw.get("cpu_cores")) + if memory_raw is None: + 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 _normalize_resource_limits(cpu_raw, memory_raw, storage_raw) + + +def _migrate_bot_resources_store(bot_id: str) -> None: + config_data = _read_bot_config(bot_id) + runtime_cfg = config_data.get("runtime") + resources_raw: Dict[str, Any] = {} + if isinstance(runtime_cfg, dict): + legacy_raw = runtime_cfg.get("resources") + if isinstance(legacy_raw, dict): + resources_raw = legacy_raw + + path = _resources_json_path(bot_id) + if not os.path.isfile(path): + _write_bot_resources( + bot_id, + resources_raw.get("cpuCores", resources_raw.get("cpu_cores")), + resources_raw.get("memoryMB", resources_raw.get("memory_mb")), + resources_raw.get("storageGB", resources_raw.get("storage_gb")), + ) + + if isinstance(runtime_cfg, dict) and "resources" in runtime_cfg: + runtime_cfg.pop("resources", None) + if not runtime_cfg: + config_data.pop("runtime", None) + _write_bot_config(bot_id, config_data) + + def _normalize_channel_extra(raw: Any) -> Dict[str, Any]: if not isinstance(raw, dict): return {} @@ -629,6 +718,26 @@ def _safe_int(raw: Any, default: int) -> int: return default +def _normalize_resource_limits(cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> Dict[str, Any]: + cpu = _safe_float(cpu_cores, 1.0) + mem = _safe_int(memory_mb, 1024) + storage = _safe_int(storage_gb, 10) + if cpu < 0: + cpu = 1.0 + if mem < 0: + mem = 1024 + if storage < 0: + storage = 10 + normalized_cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) + normalized_mem = 0 if mem == 0 else min(65536, max(256, mem)) + normalized_storage = 0 if storage == 0 else min(1024, max(1, storage)) + return { + "cpu_cores": normalized_cpu, + "memory_mb": normalized_mem, + "storage_gb": normalized_storage, + } + + def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str: path = os.path.join(_workspace_root(bot_id), filename) if not os.path.isfile(path): @@ -670,6 +779,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: api_base = str(provider_cfg.get("apiBase") or "").strip() soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD) + resources = _read_bot_resources(bot.id, config_data=config_data) return { "llm_provider": llm_provider, @@ -679,6 +789,9 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: "temperature": _safe_float(agents_defaults.get("temperature"), 0.2), "top_p": _safe_float(agents_defaults.get("topP"), 1.0), "max_tokens": _safe_int(agents_defaults.get("maxTokens"), 8192), + "cpu_cores": resources["cpu_cores"], + "memory_mb": resources["memory_mb"], + "storage_gb": resources["storage_gb"], "send_progress": send_progress, "send_tool_hints": send_tool_hints, "soul_md": soul_md, @@ -707,6 +820,9 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: "temperature": _safe_float(runtime.get("temperature"), 0.2), "top_p": _safe_float(runtime.get("top_p"), 1.0), "max_tokens": _safe_int(runtime.get("max_tokens"), 8192), + "cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0), + "memory_mb": _safe_int(runtime.get("memory_mb"), 1024), + "storage_gb": _safe_int(runtime.get("storage_gb"), 10), "send_progress": bool(runtime.get("send_progress")), "send_tool_hints": bool(runtime.get("send_tool_hints")), "soul_md": runtime.get("soul_md") or "", @@ -749,6 +865,9 @@ def _sync_workspace_channels( "temperature": _safe_float(snapshot.get("temperature"), 0.2), "top_p": _safe_float(snapshot.get("top_p"), 1.0), "max_tokens": _safe_int(snapshot.get("max_tokens"), 8192), + "cpu_cores": _safe_float(snapshot.get("cpu_cores"), 1.0), + "memory_mb": _safe_int(snapshot.get("memory_mb"), 1024), + "storage_gb": _safe_int(snapshot.get("storage_gb"), 10), "send_progress": bool(snapshot.get("send_progress")), "send_tool_hints": bool(snapshot.get("send_tool_hints")), "tools_config_json": json.dumps(_normalize_tools_config(snapshot.get("tools_config")), ensure_ascii=False), @@ -756,6 +875,14 @@ def _sync_workspace_channels( if isinstance(runtime_overrides, dict): for key, value in runtime_overrides.items(): bot_data[key] = value + resources = _normalize_resource_limits( + bot_data.get("cpu_cores"), + bot_data.get("memory_mb"), + bot_data.get("storage_gb"), + ) + bot_data["cpu_cores"] = resources["cpu_cores"] + bot_data["memory_mb"] = resources["memory_mb"] + bot_data["storage_gb"] = resources["storage_gb"] if "tools_config" in bot_data: bot_data["tools_config_json"] = json.dumps(_normalize_tools_config(bot_data.get("tools_config")), ensure_ascii=False) @@ -790,6 +917,12 @@ def _sync_workspace_channels( bot_data=bot_data, channels=normalized_channels, ) + _write_bot_resources( + bot_id, + bot_data.get("cpu_cores"), + bot_data.get("memory_mb"), + bot_data.get("storage_gb"), + ) def reconcile_image_registry(session: Session): @@ -953,6 +1086,43 @@ def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tupl return root, target +def _calc_dir_size_bytes(path: str) -> int: + total = 0 + if not os.path.exists(path): + return 0 + for root, _, files in os.walk(path): + for filename in files: + try: + file_path = os.path.join(root, filename) + if os.path.islink(file_path): + continue + total += os.path.getsize(file_path) + except Exception: + continue + return max(0, total) + + +def _is_image_attachment_path(path: str) -> bool: + lower = str(path or "").strip().lower() + return lower.endswith(".png") or lower.endswith(".jpg") or lower.endswith(".jpeg") or lower.endswith(".webp") + + +def _is_video_attachment_path(path: str) -> bool: + lower = str(path or "").strip().lower() + return ( + lower.endswith(".mp4") + or lower.endswith(".mov") + or lower.endswith(".m4v") + or lower.endswith(".webm") + or lower.endswith(".mkv") + or lower.endswith(".avi") + ) + + +def _is_visual_attachment_path(path: str) -> bool: + return _is_image_attachment_path(path) or _is_video_attachment_path(path) + + def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, Any]]: rows: List[Dict[str, Any]] = [] try: @@ -1157,6 +1327,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session session.add(bot) session.commit() session.refresh(bot) + resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb) _write_env_store(payload.id, _normalize_env_params(payload.env_params)) _sync_workspace_channels( session, @@ -1174,6 +1345,9 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session "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, @@ -1192,9 +1366,66 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session @app.get("/api/bots") def list_bots(session: Session = Depends(get_session)): 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" + session.add(bot) + dirty = True + if dirty: + session.commit() + for bot in bots: + session.refresh(bot) return [_serialize_bot(bot) for bot in bots] +@app.get("/api/bots/{bot_id}/resources") +def get_bot_resources(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") + + 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) + configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024 + workspace_percent = 0.0 + if configured_storage_bytes > 0: + workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.0 + + limits = runtime.get("limits") or {} + cpu_limited = (limits.get("cpu_cores") or 0) > 0 + memory_limited = (limits.get("memory_bytes") or 0) > 0 + storage_limited = bool(limits.get("storage_bytes")) or bool(limits.get("storage_opt_raw")) + + return { + "bot_id": bot_id, + "docker_status": runtime.get("docker_status") or bot.docker_status, + "configured": configured, + "runtime": runtime, + "workspace": { + "path": workspace_root, + "usage_bytes": workspace_bytes, + "configured_limit_bytes": configured_storage_bytes if configured_storage_bytes > 0 else None, + "usage_percent": max(0.0, workspace_percent), + }, + "enforcement": { + "cpu_limited": cpu_limited, + "memory_limited": memory_limited, + "storage_limited": storage_limited, + }, + "note": ( + "Resource value 0 means unlimited. CPU/Memory limits come from Docker HostConfig and are enforced by cgroup. " + "Storage limit depends on Docker storage driver support." + ), + "collected_at": datetime.utcnow().isoformat() + "Z", + } + + @app.put("/api/bots/{bot_id}") def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) @@ -1227,6 +1458,9 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend "temperature", "top_p", "max_tokens", + "cpu_cores", + "memory_mb", + "storage_gb", "soul_md", "agents_md", "user_md", @@ -1244,6 +1478,13 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend runtime_overrides["soul_md"] = runtime_overrides["system_prompt"] if "soul_md" in runtime_overrides and "system_prompt" not in runtime_overrides: runtime_overrides["system_prompt"] = runtime_overrides["soul_md"] + if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()): + normalized_resources = _normalize_resource_limits( + runtime_overrides.get("cpu_cores"), + runtime_overrides.get("memory_mb"), + runtime_overrides.get("storage_gb"), + ) + runtime_overrides.update(normalized_resources) db_fields = {"name", "image_tag"} for key, value in update_data.items(): @@ -1280,20 +1521,35 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)): raise HTTPException(status_code=404, detail="Bot not found") _sync_workspace_channels(session, bot_id) + runtime_snapshot = _read_bot_runtime_snapshot(bot) env_params = _read_env_store(bot_id) 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 success: - bot.docker_status = "RUNNING" + if not success: + bot.docker_status = "STOPPED" session.add(bot) session.commit() - return {"status": "started"} + raise HTTPException(status_code=500, detail=f"Failed to start container with image {bot.image_tag}") - 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() + raise HTTPException( + status_code=500, + detail="Bot container failed shortly after startup. Check bot logs/config.", + ) + session.add(bot) + session.commit() + return {"status": "started"} @app.post("/api/bots/{bot_id}/stop") @@ -1421,65 +1677,97 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio if not filename.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Only .zip skill package is supported") + max_bytes = UPLOAD_MAX_MB * 1024 * 1024 + tmp_zip_path: Optional[str] = None + total_size = 0 try: - zip_bytes = await file.read() - if not zip_bytes: - raise HTTPException(status_code=400, detail="Zip package is empty") - archive = zipfile.ZipFile(io.BytesIO(zip_bytes)) + with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip: + tmp_zip_path = tmp_zip.name + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + total_size += len(chunk) + if total_size > max_bytes: + raise HTTPException( + status_code=413, + detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)", + ) + tmp_zip.write(chunk) except Exception: + if tmp_zip_path and os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) + raise + finally: + await file.close() + + if total_size == 0: + if tmp_zip_path and os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) + raise HTTPException(status_code=400, detail="Zip package is empty") + + try: + archive = zipfile.ZipFile(tmp_zip_path) + except Exception: + if tmp_zip_path and os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) raise HTTPException(status_code=400, detail="Invalid zip file") skills_root = _skills_root(bot_id) os.makedirs(skills_root, exist_ok=True) installed: List[str] = [] - with archive: - members = archive.infolist() - file_members = [m for m in members if not m.is_dir()] - if not file_members: - raise HTTPException(status_code=400, detail="Zip package has no files") + try: + with archive: + members = archive.infolist() + file_members = [m for m in members if not m.is_dir()] + if not file_members: + raise HTTPException(status_code=400, detail="Zip package has no files") - top_names: List[str] = [] - for member in file_members: - raw_name = str(member.filename or "").replace("\\", "/").lstrip("/") - if not raw_name: - continue - first = raw_name.split("/", 1)[0].strip() - if not _is_valid_top_level_skill_name(first): - raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}") - if first not in top_names: - top_names.append(first) - - if not top_names: - raise HTTPException(status_code=400, detail="Zip package has no valid skill entries") - - conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))] - if conflicts: - raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}") - - with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=skills_root) as tmp_dir: - tmp_root = os.path.abspath(tmp_dir) - for member in members: + top_names: List[str] = [] + for member in file_members: raw_name = str(member.filename or "").replace("\\", "/").lstrip("/") if not raw_name: continue - target = os.path.abspath(os.path.join(tmp_root, raw_name)) - if os.path.commonpath([tmp_root, target]) != tmp_root: - raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}") - if member.is_dir(): - os.makedirs(target, exist_ok=True) - continue - os.makedirs(os.path.dirname(target), exist_ok=True) - with archive.open(member, "r") as source, open(target, "wb") as dest: - shutil.copyfileobj(source, dest) + first = raw_name.split("/", 1)[0].strip() + if not _is_valid_top_level_skill_name(first): + raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}") + if first not in top_names: + top_names.append(first) - for name in top_names: - src = os.path.join(tmp_root, name) - dst = os.path.join(skills_root, name) - if not os.path.exists(src): - continue - shutil.move(src, dst) - installed.append(name) + if not top_names: + raise HTTPException(status_code=400, detail="Zip package has no valid skill entries") + + conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))] + if conflicts: + raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}") + + with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=skills_root) as tmp_dir: + tmp_root = os.path.abspath(tmp_dir) + for member in members: + raw_name = str(member.filename or "").replace("\\", "/").lstrip("/") + if not raw_name: + continue + target = os.path.abspath(os.path.join(tmp_root, raw_name)) + if os.path.commonpath([tmp_root, target]) != tmp_root: + raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}") + if member.is_dir(): + os.makedirs(target, exist_ok=True) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with archive.open(member, "r") as source, open(target, "wb") as dest: + shutil.copyfileobj(source, dest) + + for name in top_names: + src = os.path.join(tmp_root, name) + dst = os.path.join(skills_root, name) + if not os.path.exists(src): + continue + shutil.move(src, dst) + installed.append(name) + finally: + if tmp_zip_path and os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) if not installed: raise HTTPException(status_code=400, detail="No skill entries installed from zip") @@ -1664,24 +1952,52 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend 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: - command_has_paths = all(p in command for p in checked_attachments) if command else False - attachment_block = "\n".join(f"- {p}" for p in checked_attachments) - 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." - ) - elif not command: - delivery_command = ( - "Please process the uploaded file(s) listed below:\n" - f"{attachment_block}" - ) + 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 "图片" + if command: + delivery_command = ( + f"{command}\n\n" + "【附件处理要求】\n" + f"1) 附件中的{media_label}已作为多模态输入提供,优先直接理解并回答。\n" + "2) 若当前模型无法直接理解图片或视频,必须先明确回复:" + "\"无法直接理解该图片/视频,正在调用工具解析。\"。\n" + "3) 在给出上述提示后,再调用工具解析附件并继续完成用户任务。\n" + "4) 除非用户明确要求,不要先调用工具读取附件文件。\n" + "5) 回复语言必须遵循 USER.md;若未指定,则与用户当前输入语言保持一致。\n" + "6) 仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。" + ) + else: + delivery_command = ( + f"请直接分析已附带的{media_label}并总结关键信息。\n" + "若当前模型无法直接理解图片或视频,请先明确回复:" + "\"无法直接理解该图片/视频,正在调用工具解析。\",然后再调用工具解析。\n" + "回复语言必须遵循 USER.md;若未指定,则与用户当前输入语言保持一致。\n" + "仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。" + ) + else: + command_has_paths = all(p in command for p in checked_attachments) if command else False + attachment_block = "\n".join(f"- {p}" for p in checked_attachments) + 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." + ) if display_command or checked_attachments: _persist_runtime_packet( @@ -1704,8 +2020,9 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend loop, ) - success = docker_manager.send_command(bot_id, delivery_command, media=checked_attachments) + success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media) if not success: + detail = docker_manager.get_last_delivery_error(bot_id) if loop and loop.is_running(): asyncio.run_coroutine_threadsafe( manager.broadcast( @@ -1715,13 +2032,16 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend "channel": "dashboard", "payload": { "state": "ERROR", - "action_msg": "command delivery failed", + "action_msg": detail or "command delivery failed", }, }, ), loop, ) - raise HTTPException(status_code=502, detail="Failed to deliver command to bot dashboard channel") + raise HTTPException( + status_code=502, + detail=f"Failed to deliver command to bot dashboard channel{': ' + detail if detail else ''}", + ) return {"success": True} @@ -1955,6 +2275,7 @@ async def upload_workspace_files( raise HTTPException(status_code=404, detail="Bot not found") if not files: raise HTTPException(status_code=400, detail="no files uploaded") + max_bytes = UPLOAD_MAX_MB * 1024 * 1024 root, upload_dir = _resolve_workspace_path(bot_id, path or "uploads") os.makedirs(upload_dir, exist_ok=True) @@ -1976,12 +2297,40 @@ async def upload_workspace_files( name = f"{base}-{int(datetime.utcnow().timestamp())}{ext}" abs_path = os.path.join(safe_dir_real, name) - content = await upload.read() - with open(abs_path, "wb") as f: - f.write(content) + 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": len(content)}) + rows.append({"name": name, "path": rel, "size": total_size}) return {"bot_id": bot_id, "files": rows} diff --git a/design/architecture.md b/design/architecture.md index 951c26c..0ac48bf 100644 --- a/design/architecture.md +++ b/design/architecture.md @@ -49,7 +49,14 @@ 1. 前端提交 Bot 创建参数(镜像、provider、模型参数、5 个 md 文件) 2. 后端校验镜像可用并写入 `botinstance` -3. 启动时生成 workspace 文件并拉起容器 +3. 启动时生成 workspace 文件并按资源配额拉起容器(CPU/内存/存储) + +### 3.4 镜像升级流 + +1. 在运维面板基础配置中切换 `image_tag` +2. 后端更新 `.nanobot/config.json` +3. 用户手动执行 stop -> start,使新镜像生效 +4. `workspace` 目录保持挂载,既有插件与脚本继续可用 ### 3.3 运维流 diff --git a/design/dashboard-nanobot.md b/design/dashboard-nanobot.md index 4d59116..1347f8a 100644 --- a/design/dashboard-nanobot.md +++ b/design/dashboard-nanobot.md @@ -34,9 +34,13 @@ - `temperature` - `top_p` - `max_tokens` + - `cpu_cores` + - `memory_mb` + - `storage_gb` - 参数编辑交互: - `temperature`、`top_p` 使用滑轨 - - `max_tokens` 使用数字输入 + - `max_tokens` 使用数字输入 + 快捷档位 + - `cpu_cores`、`memory_mb`、`storage_gb` 使用数字输入 - 代理文件配置项(全部可编辑并持久化): - `AGENTS.md` - `SOUL.md` @@ -91,6 +95,17 @@ - `.nanobot/workspace/USER.md` - `.nanobot/workspace/TOOLS.md` - `.nanobot/workspace/IDENTITY.md` +- 容器资源配额来自 `config.json.runtime.resources`: + - `cpuCores` + - `memoryMB` + - `storageGB` + +### 3.3 基础镜像升级策略 + +- 运维面板基础配置允许切换 `image_tag`。 +- 切换镜像时后端仅更新配置文件,不自动重启容器。 +- 需由用户手动执行停止/启动,使新镜像生效。 +- 因 workspace 挂载路径不变,已安装插件、脚本和产物文件会保留。 ### 3.3 Provider 测试接口 diff --git a/design/database.md b/design/database.md index 3307ac4..71237db 100644 --- a/design/database.md +++ b/design/database.md @@ -1,4 +1,4 @@ -# Dashboard Nanobot 数据库设计文档(同步到当前代码) +# Dashboard Nanobot 数据库设计文档(当前实现) 数据库默认使用 SQLite:`data/nanobot_dashboard.db`。 @@ -6,29 +6,12 @@ ```mermaid erDiagram - BOTINSTANCE ||--o{ CHANNELROUTE : "路由" - BOTINSTANCE ||--o{ BOTMESSAGE : "消息" - NANOBOTIMAGE ||--o{ BOTINSTANCE : "被引用" + BOTINSTANCE ||--o{ BOTMESSAGE : "messages" + NANOBOTIMAGE ||--o{ BOTINSTANCE : "referenced by" BOTINSTANCE { string id PK string name - text system_prompt - text soul_md - text agents_md - text user_md - text tools_md - text identity_md - text tools_config_json - string llm_provider - string llm_model - string api_key - string api_base - float temperature - float top_p - int max_tokens - float presence_penalty - float frequency_penalty string workspace_dir UK string docker_status string image_tag @@ -55,76 +38,48 @@ erDiagram string source_dir datetime created_at } - - CHANNELROUTE { - int id PK - string bot_id FK - string channel_type - string external_app_id - string app_secret - text extra_config - int internal_port - boolean is_active - } ``` -## 2. 表说明 +## 2. 设计原则 -### 2.1 `botinstance` +- 数据库只保留运行索引和历史消息。 +- Bot 参数(模型、渠道、资源配额、5 个 MD 文件)统一持久化在: + - `.nanobot/config.json` + - `.nanobot/workspace/*.md` + - `.nanobot/env.json` +- `channelroute` 已废弃,不再使用数据库存储渠道。 -存储机器人实例核心配置与运行参数。 +## 3. 表说明 -- 运行与镜像:`docker_status`、`image_tag` -- LLM 基础:`llm_provider`、`llm_model`、`api_key`、`api_base` -- LLM 参数:`temperature`、`top_p`、`max_tokens` -- Agent Bootstrap 内容:`soul_md`、`agents_md`、`user_md`、`tools_md`、`identity_md` -- 工具配置:`tools_config_json` -- 运行态:`current_state`、`last_action` +### 3.1 `botinstance` -### 2.2 `botmessage` +仅存基础索引与运行态: -保存 Dashboard 渠道持久化对话消息。 +- 标识与展示:`id`、`name` +- 容器与镜像:`docker_status`、`image_tag` +- 运行状态:`current_state`、`last_action` +- 路径与时间:`workspace_dir`、`created_at`、`updated_at` -- `role`:`user | assistant` -- `text`:消息正文 -- `media_json`:附件路径列表(JSON 字符串) +### 3.2 `botmessage` -### 2.3 `nanobotimage` +Dashboard 渠道对话历史(用于会话回放): -镜像登记表(只记录显式登记镜像)。 +- `role`: `user | assistant` +- `text`: 文本内容 +- `media_json`: 附件相对路径 JSON + +### 3.3 `nanobotimage` + +基础镜像登记表(手动注册): - `tag`: 如 `nanobot-base:v0.1.4` -- `status`: `READY | BUILDING | ERROR | UNKNOWN` -- `source_dir`: 当前手工模式通常为 `manual` +- `status`: `READY | UNKNOWN | ERROR` +- `source_dir`: 来源标识(通常 `manual`) -### 2.4 `channelroute` +## 4. 迁移策略 -渠道路由映射表。 - -- `dashboard` 为内置渠道,不允许删除或禁用。 -- `extra_config` 保存各渠道差异化字段。 - -## 3. 已移除的数据表 - -以下旧表已废弃,并在服务启动时自动删除: - -- `skillregistry` -- `botskillmapping` - -技能管理改为 **workspace 文件系统模式**: - -- 路径:`workspace/bots/{bot_id}/.nanobot/workspace/skills/` -- 管理 API:`/api/bots/{bot_id}/skills`、`/api/bots/{bot_id}/skills/upload`、`/api/bots/{bot_id}/skills/{skill_name}` - -## 4. 启动迁移策略(当前实现) - -服务启动时自动执行: +服务启动时: 1. `SQLModel.metadata.create_all(engine)` -2. 删除旧 skills 表(`DROP TABLE IF EXISTS botskillmapping/skillregistry`) -3. 针对 `botinstance` 和 `botmessage` 做增量列补齐(`ALTER TABLE ADD COLUMN`) - -## 5. 安全与一致性 - -- `api_key`、`app_secret` 建议加密存储(当前代码为明文字段,生产需加密层)。 -- 启动 Bot 前,以数据库字段 + 渠道路由重新生成 workspace 文件,确保配置一致性。 +2. 清理废弃表:`DROP TABLE IF EXISTS channelroute` +3. 对 `botinstance` 做列对齐,删除历史遗留配置列(保留当前最小字段集) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6d0a711..453eab6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,6 +15,7 @@ services: APP_PORT: 8000 APP_RELOAD: "false" DATABASE_ECHO: "false" + UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100} DATA_ROOT: ${HOST_DATA_ROOT} BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} DATABASE_URL: ${DATABASE_URL:-} diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf index 62b92ed..535cfac 100644 --- a/frontend/docker/nginx.conf +++ b/frontend/docker/nginx.conf @@ -5,6 +5,7 @@ upstream nanobot_backend { server { listen 80; server_name _; + client_max_body_size 100m; root /usr/share/nginx/html; index index.html; @@ -12,6 +13,9 @@ server { location /api/ { proxy_pass http://nanobot_backend/api/; proxy_http_version 1.1; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index eab9fb1..fbc2116 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -6,7 +6,7 @@ export const dashboardEn = { thinking: 'Nanobot is thinking...', fileReadFail: 'Failed to read file.', workspaceLoadFail: 'Failed to load workspace tree.', - channelSaved: 'Channel saved.', + channelSaved: 'Channel saved (effective after bot restart).', channelSaveFail: 'Failed to save channel.', channelAddFail: 'Failed to add channel.', channelDeleteConfirm: (channelType: string) => `Delete channel ${channelType}?`, @@ -18,13 +18,14 @@ export const dashboardEn = { uploadFile: 'Upload file', uploadingFile: 'Uploading...', uploadFail: 'File upload failed.', + uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`, attachmentMessage: '[attachment message]', removeAttachment: 'Remove attachment', sendFailMsg: (msg: string) => `Command delivery failed: ${msg}`, providerRequired: 'Set provider/model/new API key before testing.', connOk: (preview: string) => (preview ? `Connection passed, models: ${preview}` : 'Connection passed'), connFail: (msg: string) => `Failed: ${msg}`, - configUpdated: 'Configuration updated.', + configUpdated: 'Configuration updated (effective after bot restart).', saveFail: 'Save failed.', deleteBotConfirm: (id: string) => `Delete bot ${id}? Workspace will be removed.`, deleteBotDone: 'Bot and workspace deleted.', @@ -105,7 +106,7 @@ export const dashboardEn = { botIdReadonly: 'Bot ID (Read-only)', botName: 'Bot Name', botNamePlaceholder: 'Bot name', - baseImageReadonly: 'Base Image (Read-only)', + baseImageReadonly: 'Base Image', modelName: 'Model Name', modelNamePlaceholder: 'e.g. qwen-plus', newApiKey: 'New API Key (optional)', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 92e9f00..0eec9d2 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -6,7 +6,7 @@ export const dashboardZhCn = { thinking: 'Nanobot 正在思考中...', fileReadFail: '文件读取失败。', workspaceLoadFail: '无法读取 workspace 目录。', - channelSaved: '渠道配置已保存。', + channelSaved: '渠道配置已保存(重启 Bot 后生效)。', channelSaveFail: '渠道保存失败。', channelAddFail: '新增渠道失败。', channelDeleteConfirm: (channelType: string) => `确认删除渠道 ${channelType}?`, @@ -18,13 +18,14 @@ export const dashboardZhCn = { uploadFile: '上传文件', uploadingFile: '上传中...', uploadFail: '文件上传失败。', + uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB:${files}`, attachmentMessage: '[附件消息]', removeAttachment: '移除附件', sendFailMsg: (msg: string) => `指令发送失败:${msg}`, providerRequired: '请填写 Provider、模型和新 API Key 后再测试。', connOk: (preview: string) => (preview ? `连接成功,模型: ${preview}` : '连接成功'), connFail: (msg: string) => `连接失败: ${msg}`, - configUpdated: '配置已更新。', + configUpdated: '配置已更新(重启 Bot 后生效)。', saveFail: '保存失败。', deleteBotConfirm: (id: string) => `确认删除 Bot ${id}?将删除对应 workspace。`, deleteBotDone: 'Bot 与 workspace 已删除。', @@ -105,7 +106,7 @@ export const dashboardZhCn = { botIdReadonly: 'Bot ID(只读)', botName: 'Bot 名称', botNamePlaceholder: '输入 Bot 名称', - baseImageReadonly: '基础镜像(只读)', + baseImageReadonly: '基础镜像', modelName: '模型名称', modelNamePlaceholder: '如 qwen-plus', newApiKey: '新的 API Key(留空不更新)', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index ed72aca..234ba34 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -619,6 +619,12 @@ background: color-mix(in oklab, var(--panel-soft) 78%, transparent); } +.ops-pending-open { + max-width: 300px; + min-width: 0; + flex: 1 1 auto; +} + .ops-chip-remove { width: 18px; height: 18px; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 807396f..acc0194 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; -import { Activity, Boxes, Check, Clock3, Download, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import { Activity, Boxes, Check, Clock3, Download, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, Square, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -115,6 +115,24 @@ interface BotChannel { locked?: boolean; } +interface NanobotImage { + tag: string; + status: string; +} + +interface DockerImage { + tag: string; + version?: string; + image_id?: string; +} + +interface BaseImageOption { + tag: string; + label: string; + disabled: boolean; + needsRegister: boolean; +} + interface WorkspaceSkillOption { id: string; name: string; @@ -125,6 +143,51 @@ interface WorkspaceSkillOption { description?: string; } +interface BotResourceSnapshot { + bot_id: string; + docker_status: string; + configured: { + cpu_cores: number; + memory_mb: number; + storage_gb: number; + }; + runtime: { + docker_status: string; + limits: { + cpu_cores?: number | null; + memory_bytes?: number | null; + storage_bytes?: number | null; + nano_cpus?: number; + storage_opt_raw?: string; + }; + usage: { + cpu_percent: number; + memory_bytes: number; + memory_limit_bytes: number; + memory_percent: number; + network_rx_bytes: number; + network_tx_bytes: number; + blk_read_bytes: number; + blk_write_bytes: number; + pids: number; + container_rw_bytes: number; + }; + }; + workspace: { + path: string; + usage_bytes: number; + configured_limit_bytes?: number | null; + usage_percent: number; + }; + enforcement: { + cpu_limited: boolean; + memory_limited: boolean; + storage_limited: boolean; + }; + note: string; + collected_at: string; +} + interface SkillUploadResponse { status: string; bot_id: string; @@ -132,6 +195,12 @@ interface SkillUploadResponse { skills: WorkspaceSkillOption[]; } +interface SystemDefaultsResponse { + limits?: { + upload_max_mb?: number; + }; +} + type BotEnvParams = Record; const providerPresets: Record = { @@ -333,6 +402,44 @@ function clampTemperature(value: number) { return Math.min(1, Math.max(0, value)); } +function clampMaxTokens(value: number) { + if (Number.isNaN(value)) return 8192; + return Math.min(32768, Math.max(256, Math.round(value))); +} + +function clampCpuCores(value: number) { + if (Number.isNaN(value)) return 1; + if (value === 0) return 0; + return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10)); +} + +function clampMemoryMb(value: number) { + if (Number.isNaN(value)) return 1024; + if (value === 0) return 0; + return Math.min(65536, Math.max(256, Math.round(value))); +} + +function clampStorageGb(value: number) { + if (Number.isNaN(value)) return 10; + if (value === 0) return 0; + return Math.min(1024, Math.max(1, Math.round(value))); +} + +function formatBytes(bytes: number): string { + const value = Number(bytes || 0); + if (!Number.isFinite(value) || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); + const scaled = value / Math.pow(1024, idx); + return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[idx]}`; +} + +function formatPercent(value: number): string { + const n = Number(value || 0); + if (!Number.isFinite(n)) return '0.00%'; + return `${Math.max(0, n).toFixed(2)}%`; +} + function formatCronSchedule(job: CronJob, isZh: boolean) { const s = job.schedule || {}; if (s.kind === 'every' && Number(s.everyMs) > 0) { @@ -375,6 +482,11 @@ export function BotDashboardModule({ const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false); const [showAgentModal, setShowAgentModal] = useState(false); + const [showResourceModal, setShowResourceModal] = useState(false); + const [resourceBotId, setResourceBotId] = useState(''); + const [resourceSnapshot, setResourceSnapshot] = useState(null); + const [resourceLoading, setResourceLoading] = useState(false); + const [resourceError, setResourceError] = useState(''); const [agentTab, setAgentTab] = useState('AGENTS'); const [isTestingProvider, setIsTestingProvider] = useState(false); const [providerTestResult, setProviderTestResult] = useState(''); @@ -408,10 +520,13 @@ export function BotDashboardModule({ const [envVisibleByKey, setEnvVisibleByKey] = useState>({}); const [isSavingChannel, setIsSavingChannel] = useState(false); const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false); + const [availableImages, setAvailableImages] = useState([]); + const [localDockerImages, setLocalDockerImages] = useState([]); const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ sendProgress: false, sendToolHints: false, }); + const [uploadMaxMb, setUploadMaxMb] = useState(100); const [newChannelType, setNewChannelType] = useState('feishu'); const [runtimeViewMode, setRuntimeViewMode] = useState('visual'); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); @@ -607,12 +722,21 @@ export function BotDashboardModule({ temperature: 0.2, top_p: 1, max_tokens: 8192, + cpu_cores: 1, + memory_mb: 1024, + storage_gb: 10, agents_md: '', soul_md: '', user_md: '', tools_md: '', identity_md: '', }); + const [paramDraft, setParamDraft] = useState({ + max_tokens: '8192', + cpu_cores: '1', + memory_mb: '1024', + storage_gb: '10', + }); const bots = useMemo(() => Object.values(activeBots), [activeBots]); const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; @@ -623,6 +747,49 @@ export function BotDashboardModule({ const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const lc = isZh ? channelsZhCn : channelsEn; + const baseImageOptions = useMemo(() => { + const readyTags = new Set( + availableImages + .filter((img) => String(img.status || '').toUpperCase() === 'READY') + .map((img) => String(img.tag || '').trim()) + .filter(Boolean), + ); + const allTags = new Set(); + localDockerImages.forEach((img) => { + const tag = String(img.tag || '').trim(); + if (tag) allTags.add(tag); + }); + availableImages.forEach((img) => { + const tag = String(img.tag || '').trim(); + if (tag) allTags.add(tag); + }); + if (editForm.image_tag) { + allTags.add(editForm.image_tag); + } + return Array.from(allTags) + .sort((a, b) => a.localeCompare(b)) + .map((tag) => { + const isReady = readyTags.has(tag); + if (isReady) { + return { tag, label: `${tag} · READY`, disabled: false, needsRegister: false }; + } + const hasInDocker = localDockerImages.some((row) => String(row.tag || '').trim() === tag); + if (hasInDocker) { + return { + tag, + label: isZh ? `${tag} · 本地镜像(未登记)` : `${tag} · local image (unregistered)`, + disabled: false, + needsRegister: true, + }; + } + return { + tag, + label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`, + disabled: true, + needsRegister: false, + }; + }); + }, [availableImages, localDockerImages, editForm.image_tag, isZh]); const runtimeMoreLabel = isZh ? '更多' : 'More'; const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false; @@ -702,6 +869,7 @@ export function BotDashboardModule({ return Boolean(full && full !== '-' && summary && full !== summary); }, [runtimeAction, runtimeActionSummary]); const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction; + const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]); const shouldCollapseProgress = (text: string) => { const normalized = String(text || '').trim(); @@ -848,6 +1016,24 @@ export function BotDashboardModule({ setShowRuntimeActionModal(false); }, [selectedBotId]); + useEffect(() => { + let alive = true; + const loadSystemDefaults = async () => { + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/system/defaults`); + const configured = Number(res.data?.limits?.upload_max_mb); + if (!Number.isFinite(configured) || configured <= 0 || !alive) return; + setUploadMaxMb(Math.max(1, Math.floor(configured))); + } catch { + // keep default limit + } + }; + void loadSystemDefaults(); + return () => { + alive = false; + }; + }, []); + useEffect(() => { if (!compactMode) { setIsCompactMobile(false); @@ -875,13 +1061,22 @@ export function BotDashboardModule({ api_base: bot.api_base || '', temperature: clampTemperature(bot.temperature ?? 0.2), top_p: bot.top_p ?? 1, - max_tokens: bot.max_tokens ?? 8192, + max_tokens: clampMaxTokens(bot.max_tokens ?? 8192), + cpu_cores: clampCpuCores(bot.cpu_cores ?? 1), + memory_mb: clampMemoryMb(bot.memory_mb ?? 1024), + storage_gb: clampStorageGb(bot.storage_gb ?? 10), agents_md: bot.agents_md || '', soul_md: bot.soul_md || bot.system_prompt || '', user_md: bot.user_md || '', tools_md: bot.tools_md || '', identity_md: bot.identity_md || '', }); + setParamDraft({ + max_tokens: String(clampMaxTokens(bot.max_tokens ?? 8192)), + cpu_cores: String(clampCpuCores(bot.cpu_cores ?? 1)), + memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)), + storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)), + }); setPendingAttachments([]); }, [selectedBotId, selectedBot?.id]); @@ -896,11 +1091,78 @@ export function BotDashboardModule({ }); }, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]); - const refresh = async () => { - const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); - setBots(res.data); + const loadImageOptions = async () => { + const [imagesRes, dockerImagesRes] = await Promise.allSettled([ + axios.get(`${APP_ENDPOINTS.apiBase}/images`), + axios.get(`${APP_ENDPOINTS.apiBase}/docker-images`), + ]); + if (imagesRes.status === 'fulfilled') { + setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []); + } else { + setAvailableImages([]); + } + if (dockerImagesRes.status === 'fulfilled') { + setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []); + } else { + setLocalDockerImages([]); + } }; + const refresh = async () => { + const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); + setBots(botsRes.data); + await loadImageOptions(); + }; + + const loadResourceSnapshot = async (botId: string) => { + if (!botId) return; + setResourceLoading(true); + setResourceError(''); + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/resources`); + setResourceSnapshot(res.data); + } catch (error: any) { + const msg = error?.response?.data?.detail || (isZh ? '读取资源监控失败。' : 'Failed to load resource metrics.'); + setResourceError(String(msg)); + } finally { + setResourceLoading(false); + } + }; + + const openResourceMonitor = (botId: string) => { + setResourceBotId(botId); + setShowResourceModal(true); + 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; + const tick = async () => { + if (stopped) return; + await loadResourceSnapshot(resourceBotId); + }; + const timer = window.setInterval(() => { + void tick(); + }, 2000); + return () => { + stopped = true; + window.clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showResourceModal, resourceBotId]); + const openWorkspaceFilePreview = async (path: string) => { if (!selectedBotId || !path) return; const normalizedPath = String(path || '').trim(); @@ -1404,15 +1666,23 @@ export function BotDashboardModule({ const onPickAttachments = async (event: ChangeEvent) => { if (!selectedBot || !event.target.files || event.target.files.length === 0) return; const files = Array.from(event.target.files); + const maxBytes = uploadMaxMb * 1024 * 1024; + const tooLarge = files.filter((f) => Number(f.size) > maxBytes); + if (tooLarge.length > 0) { + const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', '); + notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' }); + event.target.value = ''; + return; + } const formData = new FormData(); files.forEach((file) => formData.append('files', file)); - formData.append('path', 'uploads'); setIsUploadingAttachments(true); try { const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`, formData, + { params: { path: 'uploads' } }, ); const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path)); if (uploaded.length > 0) { @@ -1518,12 +1788,43 @@ export function BotDashboardModule({ payload.llm_provider = editForm.llm_provider; payload.llm_model = editForm.llm_model; payload.api_base = editForm.api_base; + payload.image_tag = editForm.image_tag; + const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag); + if (selectedImageOption?.disabled) { + throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.'); + } + if (selectedImageOption?.needsRegister) { + await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, { + tag: editForm.image_tag, + source_dir: 'manual', + }); + } if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim(); } if (mode === 'params') { payload.temperature = clampTemperature(Number(editForm.temperature)); payload.top_p = Number(editForm.top_p); - payload.max_tokens = Number(editForm.max_tokens); + const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens)); + const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores)); + const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb)); + const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb)); + payload.max_tokens = normalizedMaxTokens; + payload.cpu_cores = normalizedCpuCores; + payload.memory_mb = normalizedMemoryMb; + payload.storage_gb = normalizedStorageGb; + setEditForm((p) => ({ + ...p, + max_tokens: normalizedMaxTokens, + cpu_cores: normalizedCpuCores, + memory_mb: normalizedMemoryMb, + storage_gb: normalizedStorageGb, + })); + setParamDraft({ + max_tokens: String(normalizedMaxTokens), + cpu_cores: String(normalizedCpuCores), + memory_mb: String(normalizedMemoryMb), + storage_gb: String(normalizedStorageGb), + }); } if (mode === 'agent') { payload.agents_md = editForm.agents_md; @@ -1726,6 +2027,17 @@ export function BotDashboardModule({
{t.image}: {bot.image_tag || '-'}
+ {bot.docker_status === 'RUNNING' ? ( ) : ( ) : null} + {showResourceModal && ( +
setShowResourceModal(false)}> +
e.stopPropagation()}> +
+
+

{isZh ? '资源监测' : 'Resource Monitor'}

+ {resourceBot?.name || resourceBotId} +
+
+ + +
+
+ + {resourceError ?
{resourceError}
: null} + {resourceSnapshot ? ( +
+
+
{isZh ? '容器状态' : 'Container'}: {resourceSnapshot.docker_status}
+
{isZh ? '采样时间' : 'Collected'}: {resourceSnapshot.collected_at}
+
+ {isZh ? 'CPU限制生效' : 'CPU limit'}:{' '} + + {Number(resourceSnapshot.configured.cpu_cores) === 0 + ? (isZh ? '不限' : 'UNLIMITED') + : (resourceSnapshot.enforcement.cpu_limited ? 'YES' : 'NO')} + +
+
+ {isZh ? '内存限制生效' : 'Memory limit'}:{' '} + + {Number(resourceSnapshot.configured.memory_mb) === 0 + ? (isZh ? '不限' : 'UNLIMITED') + : (resourceSnapshot.enforcement.memory_limited ? 'YES' : 'NO')} + +
+
+ {isZh ? '存储限制生效' : 'Storage limit'}:{' '} + + {Number(resourceSnapshot.configured.storage_gb) === 0 + ? (isZh ? '不限' : 'UNLIMITED') + : (resourceSnapshot.enforcement.storage_limited ? 'YES' : 'NO')} + +
+
+ +
+
+
{isZh ? '配置配额' : 'Configured Limits'}
+
CPU{Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : resourceSnapshot.configured.cpu_cores}
+
{isZh ? '内存' : 'Memory'}{Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.memory_mb} MB`}
+
{isZh ? '存储' : 'Storage'}{Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.storage_gb} GB`}
+
+ +
+
{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}
+
CPU{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}
+
{isZh ? '内存' : 'Memory'}{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}
+
{isZh ? '存储' : 'Storage'}{resourceSnapshot.runtime.limits.storage_bytes ? formatBytes(resourceSnapshot.runtime.limits.storage_bytes) : (resourceSnapshot.runtime.limits.storage_opt_raw || (Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-'))}
+
+
+ +
+
{isZh ? '实时使用' : 'Live Usage'}
+
CPU{formatPercent(resourceSnapshot.runtime.usage.cpu_percent)}
+
{isZh ? '内存' : 'Memory'}{formatBytes(resourceSnapshot.runtime.usage.memory_bytes)} / {resourceSnapshot.runtime.usage.memory_limit_bytes > 0 ? formatBytes(resourceSnapshot.runtime.usage.memory_limit_bytes) : '-'}
+
{isZh ? '内存占比' : 'Memory %'}{formatPercent(resourceSnapshot.runtime.usage.memory_percent)}
+
{isZh ? '工作区占用' : 'Workspace Usage'}{formatBytes(resourceSnapshot.workspace.usage_bytes)} / {resourceSnapshot.workspace.configured_limit_bytes ? formatBytes(resourceSnapshot.workspace.configured_limit_bytes) : '-'}
+
{isZh ? '工作区占比' : 'Workspace %'}{formatPercent(resourceSnapshot.workspace.usage_percent)}
+
{isZh ? '网络 I/O' : 'Network I/O'}RX {formatBytes(resourceSnapshot.runtime.usage.network_rx_bytes)} · TX {formatBytes(resourceSnapshot.runtime.usage.network_tx_bytes)}
+
{isZh ? '磁盘 I/O' : 'Block I/O'}R {formatBytes(resourceSnapshot.runtime.usage.blk_read_bytes)} · W {formatBytes(resourceSnapshot.runtime.usage.blk_write_bytes)}
+
PIDs{resourceSnapshot.runtime.usage.pids || 0}
+
+ +
+ {resourceSnapshot.note} + {isZh ? '(界面规则:资源配置填写 0 表示不限制)' : ' (UI rule: value 0 means unlimited)'} +
+
+ ) : ( +
{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}
+ )} +
+
+ )} + {showBaseModal && (
setShowBaseModal(false)}>
e.stopPropagation()}> @@ -2169,7 +2610,22 @@ export function BotDashboardModule({ setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} /> - + + {baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? ( +
+ {isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'} +
+ ) : null} setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
- setEditForm((p) => ({ ...p, max_tokens: Number(e.target.value) }))} /> + setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))} + /> +
+ {[4096, 8192, 16384, 32768].map((value) => ( + + ))} +
+ + setParamDraft((p) => ({ ...p, cpu_cores: e.target.value }))} + /> + + setParamDraft((p) => ({ ...p, memory_mb: e.target.value }))} + /> + + setParamDraft((p) => ({ ...p, storage_gb: e.target.value }))} + /> +
{isZh ? '提示:填写 0 表示不限制(保存后需手动重启 Bot 生效)。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}
diff --git a/frontend/src/modules/onboarding/BotWizardModule.tsx b/frontend/src/modules/onboarding/BotWizardModule.tsx index 13e4bdb..9830f9d 100644 --- a/frontend/src/modules/onboarding/BotWizardModule.tsx +++ b/frontend/src/modules/onboarding/BotWizardModule.tsx @@ -104,6 +104,9 @@ const initialForm = { temperature: 0.2, top_p: 1.0, max_tokens: 8192, + cpu_cores: 1, + memory_mb: 1024, + storage_gb: 10, soul_md: FALLBACK_SOUL_MD, agents_md: FALLBACK_AGENTS_MD, @@ -143,6 +146,10 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro const [newChannelType, setNewChannelType] = useState('feishu'); const [form, setForm] = useState(initialForm); const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(FALLBACK_AGENTS_MD); + const [maxTokensDraft, setMaxTokensDraft] = useState(String(initialForm.max_tokens)); + const [cpuCoresDraft, setCpuCoresDraft] = useState(String(initialForm.cpu_cores)); + const [memoryMbDraft, setMemoryMbDraft] = useState(String(initialForm.memory_mb)); + const [storageGbDraft, setStorageGbDraft] = useState(String(initialForm.storage_gb)); const readyImages = useMemo(() => images.filter((img) => img.status === 'READY'), [images]); const isZh = locale === 'zh'; @@ -215,6 +222,10 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro } if (step === 2) { + commitMaxTokensDraft(maxTokensDraft); + commitCpuCoresDraft(cpuCoresDraft); + commitMemoryMbDraft(memoryMbDraft); + commitStorageGbDraft(storageGbDraft); if (!form.id || !form.name || !form.api_key || !form.image_tag || !form.llm_model) { notify(ui.requiredBase, { tone: 'warning' }); return; @@ -271,6 +282,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro temperature: clampTemperature(Number(form.temperature)), top_p: Number(form.top_p), max_tokens: Number(form.max_tokens), + cpu_cores: Number(form.cpu_cores), + memory_mb: Number(form.memory_mb), + storage_gb: Number(form.storage_gb), soul_md: form.soul_md, agents_md: form.agents_md, user_md: form.user_md, @@ -295,6 +309,10 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro onCreated?.(); onGoDashboard?.(); setForm(initialForm); + setMaxTokensDraft(String(initialForm.max_tokens)); + setCpuCoresDraft(String(initialForm.cpu_cores)); + setMemoryMbDraft(String(initialForm.memory_mb)); + setStorageGbDraft(String(initialForm.storage_gb)); setStep(1); setTestResult(''); notify(ui.created, { tone: 'success' }); @@ -389,6 +407,41 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro if (Number.isNaN(value)) return 0.2; return Math.min(1, Math.max(0, value)); }; + const clampCpuCores = (value: number) => { + if (Number.isNaN(value)) return 1; + if (value === 0) return 0; + return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10)); + }; + const clampMemoryMb = (value: number) => { + if (Number.isNaN(value)) return 1024; + if (value === 0) return 0; + return Math.min(65536, Math.max(256, Math.round(value))); + }; + const clampStorageGb = (value: number) => { + if (Number.isNaN(value)) return 10; + if (value === 0) return 0; + return Math.min(1024, Math.max(1, Math.round(value))); + }; + const commitMaxTokensDraft = (raw: string) => { + const next = clampMaxTokens(Number(raw)); + setForm((p) => ({ ...p, max_tokens: next })); + setMaxTokensDraft(String(next)); + }; + const commitCpuCoresDraft = (raw: string) => { + const next = clampCpuCores(Number(raw)); + setForm((p) => ({ ...p, cpu_cores: next })); + setCpuCoresDraft(String(next)); + }; + const commitMemoryMbDraft = (raw: string) => { + const next = clampMemoryMb(Number(raw)); + setForm((p) => ({ ...p, memory_mb: next })); + setMemoryMbDraft(String(next)); + }; + const commitStorageGbDraft = (raw: string) => { + const next = clampStorageGb(Number(raw)); + setForm((p) => ({ ...p, storage_gb: next })); + setStorageGbDraft(String(next)); + }; const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => { setForm((prev) => { @@ -577,12 +630,64 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro step="1" min="256" max="32768" - value={form.max_tokens} - onChange={(e) => setForm((p) => ({ ...p, max_tokens: clampMaxTokens(Number(e.target.value)) }))} - onBlur={(e) => setForm((p) => ({ ...p, max_tokens: clampMaxTokens(Number(e.target.value)) }))} + value={maxTokensDraft} + onChange={(e) => setMaxTokensDraft(e.target.value)} + onBlur={(e) => commitMaxTokensDraft(e.target.value)} />
{ui.tokenRange}
+
+ {[4096, 8192, 16384, 32768].map((value) => ( + + ))} +
+
+ {isZh ? '资源配额' : 'Resource Limits'} +
+ + setCpuCoresDraft(e.target.value)} + onBlur={(e) => commitCpuCoresDraft(e.target.value)} + /> + + setMemoryMbDraft(e.target.value)} + onBlur={(e) => commitMemoryMbDraft(e.target.value)} + /> + + setStorageGbDraft(e.target.value)} + onBlur={(e) => commitStorageGbDraft(e.target.value)} + /> +
{isZh ? '提示:填写 0 表示不限制。' : 'Tip: value 0 means unlimited.'}
@@ -664,6 +769,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
Temperature: {form.temperature.toFixed(2)}
Top P: {form.top_p.toFixed(2)}
Max Tokens: {form.max_tokens}
+
CPU: {Number(form.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : form.cpu_cores}
+
{isZh ? '内存' : 'Memory'}: {Number(form.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${form.memory_mb} MB`}
+
{isZh ? '存储' : 'Storage'}: {Number(form.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${form.storage_gb} GB`}
{ui.channels}: {configuredChannelsLabel}
{ui.tools}: {envEntries.map(([k]) => k).join(', ') || '-'}
diff --git a/frontend/src/types/bot.ts b/frontend/src/types/bot.ts index 81dc498..edc72fe 100644 --- a/frontend/src/types/bot.ts +++ b/frontend/src/types/bot.ts @@ -27,6 +27,9 @@ export interface BotState { temperature?: number; top_p?: number; max_tokens?: number; + cpu_cores?: number; + memory_mb?: number; + storage_gb?: number; send_progress?: boolean; send_tool_hints?: boolean; soul_md?: string;