commit d7a05651b7f9522accc5646cdf489d8eec619acd Author: mula.liu Date: Mon Mar 2 00:26:03 2026 +0800 v0.1.1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2e82a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +**/.DS_Store + +frontend/node_modules +frontend/dist +backend/venv + +data +workspace + +**/__pycache__ +**/*.pyc +**/*.pyo +**/*.pyd + +*.log diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..404d39d --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,31 @@ +# Public exposed port (only nginx is exposed) +NGINX_PORT=8080 + +# REQUIRED absolute host paths. +# They must exist and be writable by docker daemon. +HOST_DATA_ROOT=/opt/dashboard-nanobot/data +HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots + +# Optional custom image tags +BACKEND_IMAGE_TAG=latest +FRONTEND_IMAGE_TAG=latest + +# Optional build acceleration for China network +# If you mirror upstream container images, replace these with your mirror registry image names. +PYTHON_BASE_IMAGE=python:3.12-slim +NODE_BASE_IMAGE=node:20-alpine +NGINX_BASE_IMAGE=nginx:alpine + +# Python package index mirror (recommended in CN) +PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn + +# NPM registry mirror (recommended in CN) +NPM_REGISTRY=https://registry.npmmirror.com + +# Optional DB override. +# Keep empty to use SQLite at: +# sqlite:///{HOST_DATA_ROOT}/nanobot_dashboard.db +# 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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5ed1d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# OS / Editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ + +# Virtual envs +.venv/ +venv/ +env/ +backend/venv/ + +# Backend runtime data +backend/__pycache__/ +backend/*.log + +# Project runtime data (generated locally) +data/ +workspace/ +engines/ + +# Frontend (Vite/Node) +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/.cache/ +frontend/coverage/ + +# Build artifacts +*.out +*.pid + +# Env / secrets +.env +.env.* +!.env.example +!.env.prod.example +backend/.env +frontend/.env +frontend/.env.* + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..75b7d5d --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Dashboard Nanobot + +Dashboard Nanobot 是面向 `nanobot` 的控制平面项目,提供镜像管理、引导创建和运行运维能力。 + +## 当前实现特性 + +- 零侵入引擎接入:通过 Docker 容器 + workspace 文件管理,不改 nanobot 源码。 +- 镜像登记流:只从 `docker images` 获取本地 `nanobot-base:*`,手工登记后才可用于创建。 +- 引导式创建:支持 provider/model 配置、模型连通性测试、标准模型参数配置。 +- Bootstrap 文件配置:支持创建时配置并持久化: + - `AGENTS.md` + - `SOUL.md` + - `USER.md` + - `TOOLS.md` + - `IDENTITY.md` +- 2D 运维 Dashboard:Bot 列表、启停、命令发送、日志流、遥测。 +- UI 全局支持:Light/Dark 切换、中文/English 切换。 + +## 架构概览 + +```mermaid +graph TD + User((User)) --> Frontend[Frontend Control Plane] + Frontend --> API[FastAPI Backend] + API --> DB[(SQLite)] + API --> Docker[Docker Daemon] + + Docker --> BotA[Bot Container A] + Docker --> BotB[Bot Container B] + + BotA --> WS[WebSocket State Stream] + BotB --> WS + WS --> Frontend +``` + +## 目录 + +```text +/dashboard-nanobot +├── backend/ +│ ├── core/ +│ ├── models/ +│ └── main.py +├── frontend/ +│ └── src/ +├── design/ +│ ├── dashboard-nanobot.md +│ ├── architecture.md +│ └── database.md +└── data/ +``` + +## 文档 + +- 技术规范书:`design/dashboard-nanobot.md` +- 架构设计:`design/architecture.md` +- 数据库设计:`design/database.md` + +## 环境变量配置 + +- 后端: + - 示例文件:`backend/.env.example` + - 本地配置:`backend/.env` + - 关键项: + - `DATABASE_URL`:数据库连接串(支持 SQLite / PostgreSQL / MySQL) + - `DATABASE_ECHO`:SQL 日志输出开关 + - `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录 + - `DEFAULT_*_MD`:创建向导默认模板来源(其中默认输出规范已并入 `DEFAULT_AGENTS_MD`) +- 前端: + - 示例文件:`frontend/.env.example` + - 本地配置:`frontend/.env` + - 关键项: + - `VITE_API_BASE`:后端 API 地址 + - `VITE_WS_BASE`:后端 WS 地址 + +创建向导中的 `soul_md / agents_md / user_md / tools_md / identity_md` +已改为后端统一下发(`GET /api/system/defaults`),前端不再写死。 + +## Docker 生产部署(推荐) + +### 目标 + +- 前后端均容器化部署 +- 后端 Python 3.12 +- 前端由 Nginx 托管静态资源并反代后端 API/WS +- 仅暴露 Nginx 端口(后端不对外暴露) +- 后端容器可通过 Docker Socket 启停 Bot 容器 + +### 文件 + +- `docker-compose.prod.yml` +- `backend/Dockerfile`(Python 3.12) +- `frontend/Dockerfile`(Nginx Web Server) +- `frontend/docker/nginx.conf` +- `.env.prod.example` +- `scripts/deploy-prod.sh` +- `scripts/stop-prod.sh` + +### 启动步骤 + +1. 准备部署变量 + - 复制 `.env.prod.example` 为 `.env.prod`(位于项目根目录) + - 配置绝对路径: + - `HOST_DATA_ROOT` + - `HOST_BOTS_WORKSPACE_ROOT` + - 中国网络建议配置加速项: + - `PIP_INDEX_URL`、`PIP_TRUSTED_HOST` + - `NPM_REGISTRY` + - 如需基础镜像加速,覆盖 `PYTHON_BASE_IMAGE` / `NODE_BASE_IMAGE` / `NGINX_BASE_IMAGE` +2. 启动服务 + - `./scripts/deploy-prod.sh` + - 或:`docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build` +3. 访问 + - `http://:${NGINX_PORT}`(默认 `8080`) + +### 关键说明 + +- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。 +- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。 +- `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径,并且在 `docker-compose.prod.yml` 中以“同路径”挂载到后端容器。 + 原因:后端通过 Docker API 创建 Bot 容器时,使用的是宿主机可见的 bind 路径。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..22de751 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,26 @@ +# Runtime paths +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: +# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard +# Show SQL statements in backend logs (debug only). +DATABASE_ECHO=true + +# Local backend server options (for `python3 main.py`) +APP_HOST=0.0.0.0 +APP_PORT=8000 +APP_RELOAD=true + +# Wizard / bootstrap default templates (use \n for line breaks) +DEFAULT_AGENTS_MD=# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 在workspace中创建目录保存输出。\n- 默认采用 Markdown(.md)格式。 +DEFAULT_SOUL_MD=# Soul\n\n你是专业的企业数字员工,表达清晰、可执行。 +DEFAULT_USER_MD=# User\n\n- 语言: 中文\n- 风格: 专业\n- 偏好: 简明且有步骤 +DEFAULT_TOOLS_MD=# Tools\n\n- 谨慎使用 shell\n- 修改文件后复核\n- 失败时说明原因并重试策略 +DEFAULT_IDENTITY_MD=# Identity\n\n- 角色: 企业数字员工\n- 领域: 运维与任务执行 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..64e852f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*.log +.env +.env.* +!.env.example +.venv/ +venv/ +env/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..788e247 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +ARG PYTHON_BASE_IMAGE=python:3.12-slim +FROM ${PYTHON_BASE_IMAGE} + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app/backend + +ARG PIP_INDEX_URL=https://pypi.org/simple +ARG PIP_TRUSTED_HOST= + +COPY backend/requirements.txt ./requirements.txt +RUN if [ -n "${PIP_INDEX_URL}" ]; then pip config set global.index-url "${PIP_INDEX_URL}"; fi \ + && if [ -n "${PIP_TRUSTED_HOST}" ]; then pip config set global.trusted-host "${PIP_TRUSTED_HOST}"; fi \ + && pip install --upgrade pip \ + && pip install -r requirements.txt + +COPY backend/ /app/backend/ + +EXPOSE 8000 + +CMD ["python", "main.py"] diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py new file mode 100644 index 0000000..12ecbd6 --- /dev/null +++ b/backend/core/config_manager.py @@ -0,0 +1,205 @@ +import json +import os +from typing import Any, Dict, List + +from core.settings import ( + DEFAULT_AGENTS_MD, + DEFAULT_IDENTITY_MD, + DEFAULT_SOUL_MD, + DEFAULT_TOOLS_MD, + DEFAULT_USER_MD, +) + + +class BotConfigManager: + def __init__(self, host_data_root: str): + self.host_data_root = host_data_root + + def update_workspace(self, bot_id: str, bot_data: Dict[str, Any], channels: List[Dict[str, Any]]): + """Generate/update nanobot workspace files and config.json.""" + bot_dir = os.path.join(self.host_data_root, bot_id) + dot_nanobot_dir = os.path.join(bot_dir, ".nanobot") + workspace_dir = os.path.join(dot_nanobot_dir, "workspace") + memory_dir = os.path.join(workspace_dir, "memory") + skills_dir = os.path.join(workspace_dir, "skills") + + for d in [dot_nanobot_dir, workspace_dir, memory_dir, skills_dir]: + os.makedirs(d, exist_ok=True) + + provider_name = (bot_data.get("llm_provider") or "openrouter").strip().lower() + model_name = (bot_data.get("llm_model") or "openai/gpt-4o-mini").strip() + api_key = (bot_data.get("api_key") or "").strip() + api_base = (bot_data.get("api_base") or "").strip() or None + + provider_alias = { + "aliyun": "dashscope", + "qwen": "dashscope", + "aliyun-qwen": "dashscope", + "moonshot": "kimi", + } + provider_name = provider_alias.get(provider_name, provider_name) + + provider_cfg: Dict[str, Any] = { + "apiKey": api_key, + } + if api_base: + provider_cfg["apiBase"] = api_base + + channels_cfg: Dict[str, Any] = { + "sendProgress": bool(bot_data.get("send_progress", False)), + "sendToolHints": bool(bot_data.get("send_tool_hints", False)), + } + + tools_cfg: Dict[str, Any] = { + "web": { + "search": { + "apiKey": "", + "maxResults": 5, + } + } + } + + raw_tools_cfg = bot_data.get("tools_config_json") + parsed_tools_cfg: Dict[str, Any] = {} + if isinstance(raw_tools_cfg, str) and raw_tools_cfg.strip(): + try: + loaded = json.loads(raw_tools_cfg) + if isinstance(loaded, dict): + parsed_tools_cfg = loaded + except Exception: + parsed_tools_cfg = {} + elif isinstance(raw_tools_cfg, dict): + parsed_tools_cfg = raw_tools_cfg + + web_cfg = parsed_tools_cfg.get("web") if isinstance(parsed_tools_cfg, dict) else None + if isinstance(web_cfg, dict): + search_cfg = web_cfg.get("search") + if isinstance(search_cfg, dict): + api_key = str(search_cfg.get("apiKey") or search_cfg.get("api_key") or "").strip() + max_results_raw = search_cfg.get("maxResults", search_cfg.get("max_results", 5)) + try: + max_results = int(max_results_raw) + except Exception: + max_results = 5 + max_results = max(1, min(max_results, 10)) + tools_cfg["web"]["search"]["apiKey"] = api_key + tools_cfg["web"]["search"]["maxResults"] = max_results + + config_data: Dict[str, Any] = { + "agents": { + "defaults": { + "model": model_name, + "temperature": float(bot_data.get("temperature") or 0.2), + "topP": float(bot_data.get("top_p") or 1.0), + "maxTokens": int(bot_data.get("max_tokens") or 8192), + } + }, + "providers": { + provider_name: provider_cfg, + }, + "channels": channels_cfg, + "tools": tools_cfg, + } + + for channel in channels: + channel_type = (channel.get("channel_type") or "").strip() + if not channel_type: + continue + raw_extra = channel.get("extra_config") + extra: Dict[str, Any] = {} + if isinstance(raw_extra, str) and raw_extra.strip(): + try: + parsed = json.loads(raw_extra) + if isinstance(parsed, dict): + extra = parsed + except Exception: + extra = {} + elif isinstance(raw_extra, dict): + extra = raw_extra + + # Dashboard channel is deprecated in DB routing. Global flags now come from bot fields. + if channel_type == "dashboard": + continue + + enabled = bool(channel.get("is_active", True)) + external = channel.get("external_app_id", "") or "" + secret = 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": 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": extra.get("allowFrom", []), + } + continue + + if channel_type == "dingtalk": + channels_cfg["dingtalk"] = { + "enabled": enabled, + "clientId": external, + "clientSecret": secret, + "allowFrom": 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": extra.get("allowFrom", []), + } + continue + + # Fallback for future custom channels. + channels_cfg[channel_type] = { + "enabled": enabled, + "appId": external, + "appSecret": secret, + **extra, + } + + config_path = os.path.join(dot_nanobot_dir, "config.json") + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config_data, f, indent=4, ensure_ascii=False) + + bootstrap_files = { + "AGENTS.md": bot_data.get("agents_md") or DEFAULT_AGENTS_MD, + "SOUL.md": bot_data.get("soul_md") or bot_data.get("system_prompt") or DEFAULT_SOUL_MD, + "USER.md": bot_data.get("user_md") or DEFAULT_USER_MD, + "TOOLS.md": bot_data.get("tools_md") or DEFAULT_TOOLS_MD, + "IDENTITY.md": bot_data.get("identity_md") or DEFAULT_IDENTITY_MD, + } + + for filename, content in bootstrap_files.items(): + file_path = os.path.join(workspace_dir, filename) + with open(file_path, "w", encoding="utf-8") as f: + f.write(str(content).strip() + "\n") + + return dot_nanobot_dir diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100644 index 0000000..cac6880 --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,80 @@ +from sqlalchemy import text +from sqlmodel import SQLModel, Session, create_engine + +from core.settings import DATABASE_ECHO, DATABASE_URL + +# Ensure table models are registered in SQLModel metadata before create_all. +from models import bot as _bot_models # noqa: F401 + +engine = create_engine(DATABASE_URL, echo=DATABASE_ECHO) + + +def _ensure_botinstance_columns() -> None: + required_columns = { + "current_state": "TEXT DEFAULT 'IDLE'", + "last_action": "TEXT", + "soul_md": "TEXT", + "agents_md": "TEXT", + "user_md": "TEXT", + "tools_md": "TEXT", + "tools_config_json": "TEXT", + "identity_md": "TEXT", + "api_base": "TEXT", + "temperature": "REAL DEFAULT 0.2", + "top_p": "REAL DEFAULT 1.0", + "max_tokens": "INTEGER DEFAULT 8192", + "presence_penalty": "REAL DEFAULT 0.0", + "frequency_penalty": "REAL DEFAULT 0.0", + "send_progress": "BOOLEAN DEFAULT 0", + "send_tool_hints": "BOOLEAN DEFAULT 0", + } + with engine.connect() as conn: + existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).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 botinstance ADD COLUMN {col} {ddl}")) + conn.commit() + + +def _ensure_botmessage_columns() -> None: + required_columns = { + "media_json": "TEXT", + } + with engine.connect() as conn: + existing_rows = conn.execute(text("PRAGMA table_info(botmessage)")).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 botmessage 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: + conn.execute(text("DROP TABLE IF EXISTS botskillmapping")) + conn.execute(text("DROP TABLE IF EXISTS skillregistry")) + conn.commit() + + +def _drop_legacy_channel_tables() -> None: + """Drop deprecated channel route table (channel config source moved to config.json).""" + with engine.connect() as conn: + conn.execute(text("DROP TABLE IF EXISTS channelroute")) + conn.commit() + + +def init_database() -> None: + SQLModel.metadata.create_all(engine) + _drop_legacy_skill_tables() + _drop_legacy_channel_tables() + _ensure_botinstance_columns() + _ensure_botmessage_columns() + + +def get_session(): + with Session(engine) as session: + yield session diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py new file mode 100644 index 0000000..c3e8419 --- /dev/null +++ b/backend/core/docker_manager.py @@ -0,0 +1,397 @@ +import os +import re +import threading +import time +import codecs +from typing import Callable, Dict, List, Optional +import json + +import docker + + +class BotDockerManager: + def __init__(self, host_data_root: str, base_image: str = "nanobot-base:v0.1.4"): + try: + self.client = docker.from_env(timeout=6) + self.client.version() + print("✅ Docker 引擎连接成功") + except Exception as e: + self.client = None + print(f"⚠️ 警告: 无法连接到 Docker 引擎。请确保 Docker Desktop 已启动。错误: {e}") + + self.host_data_root = host_data_root + self.base_image = base_image + self.active_monitors = {} + + def has_image(self, tag: str) -> bool: + if not self.client: + return False + try: + self.client.images.get(tag) + return True + except docker.errors.ImageNotFound: + return False + except Exception: + return False + + def list_images_by_repo(self, repository: str = "nanobot-base") -> List[Dict[str, str]]: + """List docker images by repository prefix, returning normalized tag/id pairs.""" + if not self.client: + return [] + rows: List[Dict[str, str]] = [] + try: + images = self.client.images.list(name=repository) + for image in images: + for tag in image.tags: + repo, _, version = tag.partition(":") + if repo != repository or not version: + continue + rows.append( + { + "tag": tag, + "version": version.removeprefix("v"), + "image_id": image.id, + } + ) + except Exception as e: + print(f"[DockerManager] list_images_by_repo failed: {e}") + return rows + + def start_bot( + self, + bot_id: str, + image_tag: Optional[str] = None, + env_vars: Optional[Dict[str, str]] = None, + on_state_change: Optional[Callable[[str, dict], None]] = None, + ) -> bool: + if not self.client: + print("❌ 错误: Docker 客户端未初始化,无法启动机器人。") + return False + + image = image_tag or self.base_image + if not self.has_image(image): + print(f"❌ 错误: 镜像不存在: {image}") + return False + + 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) + + 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 + + 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", + ) + + 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 as e: + print(f"[DockerManager] Error starting bot {bot_id}: {e}") + return False + + def ensure_monitor(self, bot_id: str, on_state_change: Callable[[str, dict], None]) -> bool: + """Ensure an active log monitor exists for a running bot container.""" + 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 docker.errors.NotFound: + return False + except Exception as e: + print(f"[DockerManager] Error ensuring monitor for {bot_id}: {e}") + return False + + def stop_bot(self, bot_id: str) -> bool: + if not self.client: + return False + container_name = f"worker_{bot_id}" + try: + container = self.client.containers.get(container_name) + 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 as e: + print(f"[DockerManager] Error stopping bot {bot_id}: {e}") + return False + + 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: + return False + media_paths = [str(v).strip().replace("\\", "/") for v in (media or []) if str(v).strip()] + + # 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): + 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) + + 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}") + 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" + ) + 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]}") + 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]}") + 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}") + return False + + def _send_command_via_host_http(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: + try: + import httpx + + container_name = f"worker_{bot_id}" + payload = {"message": command, "media": media or []} + container = self.client.containers.get(container_name) + ip_address = container.attrs["NetworkSettings"]["IPAddress"] or "127.0.0.1" + target_url = f"http://{ip_address}:9000/chat" + + with httpx.Client(timeout=4.0) as client: + 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]}") + return False + except Exception as e: + print(f"[DockerManager] host HTTP exception: {e}") + return False + + def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]: + if not self.client: + return [] + container_name = f"worker_{bot_id}" + try: + container = self.client.containers.get(container_name) + 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 as e: + print(f"[DockerManager] Error reading logs for {bot_id}: {e}") + return [] + + def _monitor_container_logs(self, bot_id: str, container, callback: Callable[[str, dict], None]): + try: + buffer = "" + dashboard_capture: Optional[str] = None + decoder = codecs.getincrementaldecoder("utf-8")("replace") + # Only tail new logs from "now" to avoid replaying historical stdout + # (which would repopulate cleared chat messages from old dashboard packets). + since_ts = int(time.time()) + for chunk in container.logs(stream=True, follow=True, since=since_ts): + if isinstance(chunk, bytes): + text = decoder.decode(chunk) + else: + text = str(chunk) + if not text: + continue + buffer += text + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + normalized = line.strip("\r").strip() + if not normalized: + continue + + if dashboard_capture is not None: + dashboard_capture = f"{dashboard_capture}\n{normalized}" + if "__DASHBOARD_DATA_END__" in dashboard_capture: + state_packet = self._parse_dashboard_packet(dashboard_capture) + if state_packet: + callback(bot_id, state_packet) + dashboard_capture = None + callback(bot_id, {"type": "RAW_LOG", "text": normalized}) + continue + + if "__DASHBOARD_DATA_START__" in normalized and "__DASHBOARD_DATA_END__" not in normalized: + dashboard_capture = normalized + callback(bot_id, {"type": "RAW_LOG", "text": normalized}) + continue + + 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 as e: + print(f"[DockerManager] Log stream closed for {bot_id}: {e}") + + def _parse_dashboard_packet(self, line: str): + 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)) + + 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}, + } + + if event_type == "ASSISTANT_MESSAGE": + if content or media: + return {"type": "ASSISTANT_MESSAGE", "channel": "dashboard", "text": content, "media": media} + 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, + } + + if content or media: + return { + "type": "ASSISTANT_MESSAGE", + "channel": "dashboard", + "text": content, + "media": media, + } + except Exception: + return None + return None + + def _parse_log_line(self, line: str): + # 1. 结构化数据解析(首选,直接从机器人总线获取) + if "__DASHBOARD_DATA_START__" in line: + packet = self._parse_dashboard_packet(line) + if packet: + return packet + + # 2. 解析全渠道运行态日志(用于右侧状态面板) + 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], + }, + } + + # 3. 备选方案:常规日志解析 + 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 diff --git a/backend/core/settings.py b/backend/core/settings.py new file mode 100644 index 0000000..a807900 --- /dev/null +++ b/backend/core/settings.py @@ -0,0 +1,126 @@ +import os +from pathlib import Path +from typing import Final +from urllib.parse import urlsplit, urlunsplit + +from dotenv import load_dotenv + +BACKEND_ROOT: Final[Path] = Path(__file__).resolve().parents[1] +PROJECT_ROOT: Final[Path] = BACKEND_ROOT.parent + +# Load backend-local env first, then fallback to project root env. +load_dotenv(BACKEND_ROOT / ".env", override=False) +load_dotenv(PROJECT_ROOT / ".env", override=False) + + +def _env_text(name: str, default: str) -> str: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).replace("\\n", "\n") + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +def _normalize_dir_path(path_value: str) -> str: + raw = str(path_value or "").strip() + if not raw: + return raw + p = Path(raw) + if p.is_absolute(): + return str(p) + return str((BACKEND_ROOT / p).resolve()) + + +DATA_ROOT: Final[str] = _normalize_dir_path(os.getenv("DATA_ROOT", str(PROJECT_ROOT / "data"))) +BOTS_WORKSPACE_ROOT: Final[str] = _normalize_dir_path( + os.getenv("BOTS_WORKSPACE_ROOT", str(PROJECT_ROOT / "workspace" / "bots")) +) + + +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()}" + + +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"): + return "mysql" + if "+" in raw: + return raw.split("+", 1)[0] + if "://" in raw: + return raw.split("://", 1)[0] + return "unknown" + + +def _mask_database_url(url: str) -> str: + raw = str(url or "").strip() + if not raw or raw.startswith("sqlite"): + return raw + try: + parsed = urlsplit(raw) + if parsed.password is None: + return raw + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + auth = parsed.username or "" + if auth: + auth = f"{auth}:***@{host}" + else: + auth = host + netloc = auth + return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + except Exception: + return raw + + +_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'}" +) +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) + +DEFAULT_AGENTS_MD: Final[str] = _env_text( + "DEFAULT_AGENTS_MD", + "# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown(.md)格式。", +).strip() + +DEFAULT_SOUL_MD: Final[str] = _env_text( + "DEFAULT_SOUL_MD", + "# Soul\n\n你是专业的企业数字员工,表达清晰、可执行。", +).strip() + +DEFAULT_USER_MD: Final[str] = _env_text( + "DEFAULT_USER_MD", + "# User\n\n- 语言: 中文\n- 风格: 专业\n- 偏好: 简明且有步骤", +).strip() + +DEFAULT_TOOLS_MD: Final[str] = _env_text( + "DEFAULT_TOOLS_MD", + "# Tools\n\n- 谨慎使用 shell\n- 修改文件后复核\n- 失败时说明原因并重试策略", +).strip() + +DEFAULT_IDENTITY_MD: Final[str] = _env_text( + "DEFAULT_IDENTITY_MD", + "# Identity\n\n- 角色: 企业数字员工\n- 领域: 运维与任务执行", +).strip() diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..265cdaf --- /dev/null +++ b/backend/main.py @@ -0,0 +1,1812 @@ +import asyncio +import io +import json +import mimetypes +import os +import re +import shutil +import tempfile +import zipfile +from datetime import datetime +from typing import Any, Dict, List, Optional + +import httpx +from pydantic import BaseModel +from fastapi import Depends, FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from fastapi.middleware.cors import CORSMiddleware +from sqlmodel import Session, select + +from core.config_manager import BotConfigManager +from core.database import engine, get_session, init_database +from core.docker_manager import BotDockerManager +from core.settings import ( + BOTS_WORKSPACE_ROOT, + DATA_ROOT, + DATABASE_ECHO, + DATABASE_ENGINE, + DATABASE_URL_DISPLAY, + DEFAULT_AGENTS_MD, + DEFAULT_IDENTITY_MD, + DEFAULT_SOUL_MD, + DEFAULT_TOOLS_MD, + DEFAULT_USER_MD, + PROJECT_ROOT, +) +from models.bot import BotInstance, BotMessage, NanobotImage + +app = FastAPI(title="Dashboard Nanobot API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True) +os.makedirs(DATA_ROOT, exist_ok=True) + +docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT) +config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT) + + +class ChannelConfigRequest(BaseModel): + channel_type: str + external_app_id: Optional[str] = None + app_secret: Optional[str] = None + internal_port: Optional[int] = None + is_active: bool = True + extra_config: Optional[Dict[str, Any]] = None + + +class ChannelConfigUpdateRequest(BaseModel): + channel_type: Optional[str] = None + external_app_id: Optional[str] = None + app_secret: Optional[str] = None + internal_port: Optional[int] = None + is_active: Optional[bool] = None + extra_config: Optional[Dict[str, Any]] = None + + +class BotCreateRequest(BaseModel): + id: str + name: str + llm_provider: str + llm_model: str + api_key: str + image_tag: str + system_prompt: Optional[str] = None + api_base: Optional[str] = None + temperature: float = 0.2 + top_p: float = 1.0 + max_tokens: int = 8192 + soul_md: Optional[str] = None + agents_md: Optional[str] = None + user_md: Optional[str] = None + tools_md: Optional[str] = None + tools_config: Optional[Dict[str, Any]] = None + env_params: Optional[Dict[str, str]] = None + identity_md: Optional[str] = None + channels: Optional[List[ChannelConfigRequest]] = None + send_progress: Optional[bool] = None + send_tool_hints: Optional[bool] = None + + +class BotUpdateRequest(BaseModel): + name: Optional[str] = None + llm_provider: Optional[str] = None + llm_model: Optional[str] = None + api_key: Optional[str] = None + api_base: Optional[str] = None + image_tag: Optional[str] = None + system_prompt: Optional[str] = None + temperature: Optional[float] = None + top_p: Optional[float] = None + max_tokens: Optional[int] = None + soul_md: Optional[str] = None + agents_md: Optional[str] = None + user_md: Optional[str] = None + tools_md: Optional[str] = None + tools_config: Optional[Dict[str, Any]] = None + env_params: Optional[Dict[str, str]] = None + identity_md: Optional[str] = None + send_progress: Optional[bool] = None + send_tool_hints: Optional[bool] = None + + +class BotToolsConfigUpdateRequest(BaseModel): + tools_config: Optional[Dict[str, Any]] = None + + +class BotEnvParamsUpdateRequest(BaseModel): + env_params: Optional[Dict[str, str]] = None + + +class CommandRequest(BaseModel): + command: Optional[str] = None + attachments: Optional[List[str]] = None + + +def _normalize_media_item(bot_id: str, value: Any) -> str: + raw = str(value or "").strip().replace("\\", "/") + if not raw: + return "" + if raw.startswith("/root/.nanobot/workspace/"): + return raw[len("/root/.nanobot/workspace/") :].lstrip("/") + root = _workspace_root(bot_id) + if os.path.isabs(raw): + try: + if os.path.commonpath([root, raw]) == root: + return os.path.relpath(raw, root).replace("\\", "/") + except Exception: + pass + return raw.lstrip("/") + + +def _normalize_media_list(raw: Any, bot_id: str) -> List[str]: + if not isinstance(raw, list): + return [] + rows: List[str] = [] + for v in raw: + s = _normalize_media_item(bot_id, v) + if s: + rows.append(s) + return rows + + +def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]): + packet_type = str(packet.get("type", "")).upper() + if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: + return + source_channel = str(packet.get("channel") or "").strip().lower() + if source_channel != "dashboard": + return + with Session(engine) as session: + bot = session.get(BotInstance, bot_id) + if not bot: + return + if packet_type == "AGENT_STATE": + payload = packet.get("payload") or {} + state = str(payload.get("state") or "").strip() + action = str(payload.get("action_msg") or payload.get("msg") or "").strip() + if state: + bot.current_state = state + if action: + bot.last_action = action[:4000] + elif packet_type == "ASSISTANT_MESSAGE": + bot.current_state = "IDLE" + text_msg = str(packet.get("text") or "").strip() + media_list = _normalize_media_list(packet.get("media"), bot_id) + if text_msg or media_list: + if text_msg: + bot.last_action = " ".join(text_msg.split())[:4000] + session.add( + BotMessage( + bot_id=bot_id, + role="assistant", + text=text_msg, + media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, + ) + ) + elif packet_type == "USER_COMMAND": + text_msg = str(packet.get("text") or "").strip() + media_list = _normalize_media_list(packet.get("media"), bot_id) + if text_msg or media_list: + session.add( + BotMessage( + bot_id=bot_id, + role="user", + text=text_msg, + media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, + ) + ) + elif packet_type == "BUS_EVENT": + # Dashboard channel emits BUS_EVENT for both progress and final replies. + # Persist only non-progress events to keep durable chat history clean. + is_progress = bool(packet.get("is_progress")) + if not is_progress: + text_msg = str(packet.get("content") or packet.get("text") or "").strip() + media_list = _normalize_media_list(packet.get("media"), bot_id) + if text_msg or media_list: + bot.current_state = "IDLE" + if text_msg: + bot.last_action = " ".join(text_msg.split())[:4000] + session.add( + BotMessage( + bot_id=bot_id, + role="assistant", + text=text_msg, + media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, + ) + ) + + bot.updated_at = datetime.utcnow() + session.add(bot) + session.commit() + + +class WSConnectionManager: + def __init__(self): + self.connections: Dict[str, List[WebSocket]] = {} + + async def connect(self, bot_id: str, websocket: WebSocket): + await websocket.accept() + self.connections.setdefault(bot_id, []).append(websocket) + + def disconnect(self, bot_id: str, websocket: WebSocket): + conns = self.connections.get(bot_id, []) + if websocket in conns: + conns.remove(websocket) + if not conns and bot_id in self.connections: + del self.connections[bot_id] + + async def broadcast(self, bot_id: str, data: Dict[str, Any]): + conns = list(self.connections.get(bot_id, [])) + for ws in conns: + try: + await ws.send_json(data) + except Exception: + self.disconnect(bot_id, ws) + + +manager = WSConnectionManager() + + +def docker_callback(bot_id: str, packet: Dict[str, Any]): + _persist_runtime_packet(bot_id, packet) + loop = getattr(app.state, "main_loop", None) + if not loop or not loop.is_running(): + return + asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop) + + +@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}") + init_database() + with Session(engine) as session: + 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) + + +def _provider_defaults(provider: str) -> tuple[str, str]: + p = provider.lower().strip() + if p in {"openrouter"}: + return "openrouter", "https://openrouter.ai/api/v1" + if p in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}: + return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1" + if p in {"kimi", "moonshot"}: + return "kimi", "https://api.moonshot.cn/v1" + if p in {"minimax"}: + return "minimax", "https://api.minimax.chat/v1" + return p, "" + + +@app.get("/api/system/defaults") +def get_system_defaults(): + return { + "templates": { + "soul_md": DEFAULT_SOUL_MD, + "agents_md": DEFAULT_AGENTS_MD, + "user_md": DEFAULT_USER_MD, + "tools_md": DEFAULT_TOOLS_MD, + "identity_md": DEFAULT_IDENTITY_MD, + } + } + + +@app.get("/api/health") +def get_health(): + try: + with Session(engine) as session: + session.exec(select(BotInstance).limit(1)).first() + return {"status": "ok", "database": DATABASE_ENGINE} + except Exception as e: + raise HTTPException(status_code=503, detail=f"database check failed: {e}") + + +def _config_json_path(bot_id: str) -> str: + return os.path.join(_bot_data_root(bot_id), "config.json") + + +def _read_bot_config(bot_id: str) -> Dict[str, Any]: + path = _config_json_path(bot_id) + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None: + 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) + os.replace(tmp, path) + + +def _normalize_channel_extra(raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + return {} + return raw + + +def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]: + ctype = str(ctype or "").strip().lower() + enabled = bool(cfg.get("enabled", True)) + port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535)) + extra: Dict[str, Any] = {} + external_app_id = "" + app_secret = "" + + if ctype == "feishu": + external_app_id = str(cfg.get("appId") or "") + app_secret = str(cfg.get("appSecret") or "") + extra = { + "encryptKey": cfg.get("encryptKey", ""), + "verificationToken": cfg.get("verificationToken", ""), + "allowFrom": cfg.get("allowFrom", []), + } + elif ctype == "dingtalk": + external_app_id = str(cfg.get("clientId") or "") + app_secret = str(cfg.get("clientSecret") or "") + extra = {"allowFrom": cfg.get("allowFrom", [])} + elif ctype == "telegram": + app_secret = str(cfg.get("token") or "") + extra = { + "proxy": cfg.get("proxy", ""), + "replyToMessage": bool(cfg.get("replyToMessage", False)), + "allowFrom": cfg.get("allowFrom", []), + } + elif ctype == "slack": + external_app_id = str(cfg.get("botToken") or "") + app_secret = str(cfg.get("appToken") or "") + extra = { + "mode": cfg.get("mode", "socket"), + "replyInThread": bool(cfg.get("replyInThread", True)), + "groupPolicy": cfg.get("groupPolicy", "mention"), + "groupAllowFrom": cfg.get("groupAllowFrom", []), + "reactEmoji": cfg.get("reactEmoji", "eyes"), + } + elif ctype == "qq": + external_app_id = str(cfg.get("appId") or "") + app_secret = str(cfg.get("secret") or "") + extra = {"allowFrom": cfg.get("allowFrom", [])} + else: + external_app_id = str( + cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "" + ) + app_secret = str( + cfg.get("appSecret") or cfg.get("clientSecret") or cfg.get("secret") or cfg.get("token") or cfg.get("appToken") or "" + ) + extra = {k: v for k, v in cfg.items() if k not in {"enabled", "port", "appId", "clientId", "botToken", "externalAppId", "appSecret", "clientSecret", "secret", "token", "appToken"}} + + return { + "id": ctype, + "bot_id": bot_id, + "channel_type": ctype, + "external_app_id": external_app_id, + "app_secret": app_secret, + "internal_port": port, + "is_active": enabled, + "extra_config": extra, + "locked": ctype == "dashboard", + } + + +def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]: + ctype = str(row.get("channel_type") or "").strip().lower() + enabled = bool(row.get("is_active", True)) + extra = _normalize_channel_extra(row.get("extra_config")) + external_app_id = str(row.get("external_app_id") or "") + app_secret = str(row.get("app_secret") or "") + port = max(1, min(int(row.get("internal_port") or 8080), 65535)) + + if ctype == "feishu": + return { + "enabled": enabled, + "appId": external_app_id, + "appSecret": app_secret, + "encryptKey": extra.get("encryptKey", ""), + "verificationToken": extra.get("verificationToken", ""), + "allowFrom": extra.get("allowFrom", []), + } + if ctype == "dingtalk": + return { + "enabled": enabled, + "clientId": external_app_id, + "clientSecret": app_secret, + "allowFrom": extra.get("allowFrom", []), + } + if ctype == "telegram": + return { + "enabled": enabled, + "token": app_secret, + "proxy": extra.get("proxy", ""), + "replyToMessage": bool(extra.get("replyToMessage", False)), + "allowFrom": extra.get("allowFrom", []), + } + if ctype == "slack": + return { + "enabled": enabled, + "mode": extra.get("mode", "socket"), + "botToken": external_app_id, + "appToken": app_secret, + "replyInThread": bool(extra.get("replyInThread", True)), + "groupPolicy": extra.get("groupPolicy", "mention"), + "groupAllowFrom": extra.get("groupAllowFrom", []), + "reactEmoji": extra.get("reactEmoji", "eyes"), + } + if ctype == "qq": + return { + "enabled": enabled, + "appId": external_app_id, + "secret": app_secret, + "allowFrom": extra.get("allowFrom", []), + } + merged = dict(extra) + merged.update( + { + "enabled": enabled, + "appId": external_app_id, + "appSecret": app_secret, + "port": port, + } + ) + return merged + + +def _get_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]: + config_data = _read_bot_config(bot.id) + channels_cfg = config_data.get("channels") + if not isinstance(channels_cfg, dict): + channels_cfg = {} + + send_progress = bool(channels_cfg.get("sendProgress", bot.send_progress)) + send_tool_hints = bool(channels_cfg.get("sendToolHints", bot.send_tool_hints)) + rows: List[Dict[str, Any]] = [ + { + "id": "dashboard", + "bot_id": bot.id, + "channel_type": "dashboard", + "external_app_id": f"dashboard-{bot.id}", + "app_secret": "", + "internal_port": 9000, + "is_active": True, + "extra_config": { + "sendProgress": send_progress, + "sendToolHints": send_tool_hints, + }, + "locked": True, + } + ] + + for ctype, cfg in channels_cfg.items(): + if ctype in {"sendProgress", "sendToolHints"}: + continue + if not isinstance(cfg, dict): + continue + rows.append(_channel_cfg_to_api_dict(bot.id, ctype, cfg)) + return rows + + +def _normalize_initial_channels(bot_id: str, channels: Optional[List[ChannelConfigRequest]]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + seen_types: set[str] = set() + for c in channels or []: + ctype = (c.channel_type or "").strip().lower() + if not ctype or ctype == "dashboard" or ctype in seen_types: + continue + seen_types.add(ctype) + rows.append( + { + "id": ctype, + "bot_id": bot_id, + "channel_type": ctype, + "external_app_id": (c.external_app_id or "").strip() or f"{ctype}-{bot_id}", + "app_secret": (c.app_secret or "").strip(), + "internal_port": max(1, min(int(c.internal_port or 8080), 65535)), + "is_active": bool(c.is_active), + "extra_config": _normalize_channel_extra(c.extra_config), + "locked": False, + } + ) + return rows + + +def _parse_message_media(bot_id: str, media_raw: Optional[str]) -> List[str]: + if not media_raw: + return [] + try: + parsed = json.loads(media_raw) + return _normalize_media_list(parsed, bot_id) + except Exception: + return [] + + +def _default_tools_config() -> Dict[str, Any]: + return { + "web": { + "search": { + "apiKey": "", + "maxResults": 5, + } + } + } + + +def _normalize_tools_config(raw: Any) -> Dict[str, Any]: + cfg = _default_tools_config() + if not isinstance(raw, dict): + return cfg + + web_raw = raw.get("web") + if isinstance(web_raw, dict): + search_raw = web_raw.get("search") + if isinstance(search_raw, dict): + api_key = str(search_raw.get("apiKey") or search_raw.get("api_key") or "").strip() + max_results_raw = search_raw.get("maxResults", search_raw.get("max_results", 5)) + try: + max_results = int(max_results_raw) + except Exception: + max_results = 5 + max_results = max(1, min(max_results, 10)) + cfg["web"]["search"]["apiKey"] = api_key + cfg["web"]["search"]["maxResults"] = max_results + return cfg + + +def _parse_tools_config(raw: Optional[str]) -> Dict[str, Any]: + if not raw: + return _default_tools_config() + try: + parsed = json.loads(raw) + except Exception: + return _default_tools_config() + return _normalize_tools_config(parsed) + + +_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") + + +def _normalize_env_params(raw: Any) -> Dict[str, str]: + if not isinstance(raw, dict): + return {} + rows: Dict[str, str] = {} + for k, v in raw.items(): + key = str(k or "").strip().upper() + if not key or not _ENV_KEY_RE.fullmatch(key): + continue + rows[key] = str(v or "").strip() + return rows + + +def _parse_env_params(raw: Any) -> Dict[str, str]: + return _normalize_env_params(raw) + + +def _sync_workspace_channels( + session: Session, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, +) -> None: + bot = session.get(BotInstance, bot_id) + if not bot: + return + channels_data = channels_override if channels_override is not None else _get_bot_channels_from_config(bot) + bot_data = bot.model_dump() + for row in channels_data: + if str(row.get("channel_type") or "").strip().lower() != "dashboard": + continue + extra = _normalize_channel_extra(row.get("extra_config")) + bot_data["send_progress"] = bool(extra.get("sendProgress", bot.send_progress)) + bot_data["send_tool_hints"] = bool(extra.get("sendToolHints", bot.send_tool_hints)) + break + normalized_channels: List[Dict[str, Any]] = [] + for row in channels_data: + ctype = str(row.get("channel_type") or "").strip().lower() + if not ctype or ctype == "dashboard": + continue + normalized_channels.append( + { + "channel_type": ctype, + "external_app_id": str(row.get("external_app_id") or ""), + "app_secret": str(row.get("app_secret") or ""), + "internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)), + "is_active": bool(row.get("is_active", True)), + "extra_config": _normalize_channel_extra(row.get("extra_config")), + } + ) + config_manager.update_workspace( + bot_id=bot_id, + bot_data=bot_data, + channels=normalized_channels, + ) + + +def reconcile_image_registry(session: Session): + """Only reconcile status for images explicitly registered in DB.""" + db_images = session.exec(select(NanobotImage)).all() + for img in db_images: + if docker_manager.has_image(img.tag): + try: + docker_img = docker_manager.client.images.get(img.tag) if docker_manager.client else None + img.image_id = docker_img.id if docker_img else img.image_id + except Exception: + pass + img.status = "READY" + else: + img.status = "UNKNOWN" + session.add(img) + + session.commit() + + +def _workspace_root(bot_id: str) -> str: + return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace")) + + +def _bot_data_root(bot_id: str) -> str: + return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")) + + +def _skills_root(bot_id: str) -> str: + return os.path.join(_workspace_root(bot_id), "skills") + + +def _is_valid_top_level_skill_name(name: str) -> bool: + text = str(name or "").strip() + if not text: + return False + if "/" in text or "\\" in text: + return False + if text in {".", ".."}: + return False + return True + + +def _read_skill_description(entry_path: str) -> str: + candidates: List[str] = [] + if os.path.isdir(entry_path): + candidates = [ + os.path.join(entry_path, "SKILL.md"), + os.path.join(entry_path, "skill.md"), + os.path.join(entry_path, "README.md"), + os.path.join(entry_path, "readme.md"), + ] + elif entry_path.lower().endswith(".md"): + candidates = [entry_path] + + for candidate in candidates: + if not os.path.isfile(candidate): + continue + try: + with open(candidate, "r", encoding="utf-8") as f: + for line in f: + text = line.strip() + if text and not text.startswith("#"): + return text[:240] + except Exception: + continue + return "" + + +def _list_workspace_skills(bot_id: str) -> List[Dict[str, Any]]: + root = _skills_root(bot_id) + os.makedirs(root, exist_ok=True) + 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: + if not name or name.startswith("."): + continue + if not _is_valid_top_level_skill_name(name): + continue + abs_path = os.path.join(root, name) + if not os.path.exists(abs_path): + continue + stat = os.stat(abs_path) + rows.append( + { + "id": name, + "name": name, + "type": "dir" if os.path.isdir(abs_path) else "file", + "path": f"skills/{name}", + "size": stat.st_size if os.path.isfile(abs_path) else None, + "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", + "description": _read_skill_description(abs_path), + } + ) + return rows + + +def _cron_store_path(bot_id: str) -> str: + return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json") + + +def _env_store_path(bot_id: str) -> str: + return os.path.join(_bot_data_root(bot_id), "env.json") + + +def _read_env_store(bot_id: str) -> Dict[str, str]: + path = _env_store_path(bot_id) + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return _normalize_env_params(data) + except Exception: + return {} + + +def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None: + 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) + os.replace(tmp, path) + + +def _read_cron_store(bot_id: str) -> Dict[str, Any]: + path = _cron_store_path(bot_id) + if not os.path.isfile(path): + return {"version": 1, "jobs": []} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return {"version": 1, "jobs": []} + jobs = data.get("jobs") + if not isinstance(jobs, list): + data["jobs"] = [] + if "version" not in data: + data["version"] = 1 + return data + except Exception: + return {"version": 1, "jobs": []} + + +def _write_cron_store(bot_id: str, store: Dict[str, Any]) -> None: + 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) + os.replace(tmp, path) + + +def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]: + root = _workspace_root(bot_id) + 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 + + +def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + try: + names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower())) + except FileNotFoundError: + return rows + + for name in names: + if name in {".DS_Store"}: + continue + abs_path = os.path.join(path, name) + rel_path = os.path.relpath(abs_path, root).replace("\\", "/") + stat = os.stat(abs_path) + base: Dict[str, Any] = { + "name": name, + "path": rel_path, + "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", + } + if os.path.isdir(abs_path): + node = {**base, "type": "dir"} + if depth > 0: + node["children"] = _build_workspace_tree(abs_path, root, depth - 1) + rows.append(node) + continue + rows.append( + { + **base, + "type": "file", + "size": stat.st_size, + "ext": os.path.splitext(name)[1].lower(), + } + ) + return rows + + +def _list_workspace_dir(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) + rel_path = os.path.relpath(abs_path, root).replace("\\", "/") + stat = os.stat(abs_path) + rows.append( + { + "name": name, + "path": rel_path, + "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 "", + "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", + } + ) + return rows + + +@app.get("/api/images", response_model=List[NanobotImage]) +def list_images(session: Session = Depends(get_session)): + reconcile_image_registry(session) + return session.exec(select(NanobotImage)).all() + + +@app.delete("/api/images/{tag:path}") +def delete_image(tag: str, session: Session = Depends(get_session)): + image = session.get(NanobotImage, tag) + if not image: + raise HTTPException(status_code=404, detail="Image not found") + + # 检查是否有机器人正在使用此镜像 + bots_using = session.exec(select(BotInstance).where(BotInstance.image_tag == tag)).all() + if bots_using: + raise HTTPException(status_code=400, detail=f"Cannot delete image: {len(bots_using)} bots are using it.") + + session.delete(image) + session.commit() + return {"status": "deleted"} + + +@app.get("/api/docker-images") +def list_docker_images(repository: str = "nanobot-base"): + rows = docker_manager.list_images_by_repo(repository) + return rows + + +@app.post("/api/images/register") +def register_image(payload: dict, session: Session = Depends(get_session)): + tag = (payload.get("tag") or "").strip() + source_dir = (payload.get("source_dir") or "manual").strip() or "manual" + if not tag: + raise HTTPException(status_code=400, detail="tag is required") + + if not docker_manager.has_image(tag): + raise HTTPException(status_code=404, detail=f"Docker image not found: {tag}") + + version = tag.split(":")[-1].removeprefix("v") if ":" in tag else tag + try: + docker_img = docker_manager.client.images.get(tag) if docker_manager.client else None + image_id = docker_img.id if docker_img else None + except Exception: + image_id = None + + row = session.get(NanobotImage, tag) + if not row: + row = NanobotImage( + tag=tag, + version=version, + status="READY", + source_dir=source_dir, + image_id=image_id, + ) + else: + row.version = version + row.status = "READY" + row.source_dir = source_dir + row.image_id = image_id + session.add(row) + session.commit() + session.refresh(row) + return row + + +@app.post("/api/providers/test") +async def test_provider(payload: dict): + provider = (payload.get("provider") or "").strip() + api_key = (payload.get("api_key") or "").strip() + model = (payload.get("model") or "").strip() + api_base = (payload.get("api_base") or "").strip() + + if not provider or not api_key: + raise HTTPException(status_code=400, detail="provider and api_key are required") + + normalized_provider, default_base = _provider_defaults(provider) + base = (api_base or default_base).rstrip("/") + + if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax"}: + raise HTTPException(status_code=400, detail=f"provider not supported for test: {provider}") + + if not base: + raise HTTPException(status_code=400, detail=f"api_base is required for provider: {provider}") + + headers = {"Authorization": f"Bearer {api_key}"} + timeout = httpx.Timeout(20.0, connect=10.0) + url = f"{base}/models" + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.get(url, headers=headers) + + if resp.status_code >= 400: + return { + "ok": False, + "provider": normalized_provider, + "status_code": resp.status_code, + "detail": resp.text[:500], + } + + data = resp.json() + models_raw = data.get("data", []) if isinstance(data, dict) else [] + model_ids: List[str] = [] + for item in models_raw[:20]: + if isinstance(item, dict) and item.get("id"): + model_ids.append(str(item["id"])) + + model_hint = "" + if model: + model_hint = "model_found" if any(model in m for m in model_ids) else "model_not_listed" + + return { + "ok": True, + "provider": normalized_provider, + "endpoint": url, + "models_preview": model_ids[:8], + "model_hint": model_hint, + } + except Exception as e: + return { + "ok": False, + "provider": normalized_provider, + "endpoint": url, + "detail": str(e), + } + + +@app.post("/api/bots", response_model=BotInstance) +def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)): + 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}") + + bot = BotInstance( + id=payload.id, + name=payload.name, + system_prompt=payload.system_prompt or payload.soul_md or DEFAULT_SOUL_MD, + soul_md=payload.soul_md 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, + tools_config_json=json.dumps(_normalize_tools_config(payload.tools_config), ensure_ascii=False), + identity_md=payload.identity_md or DEFAULT_IDENTITY_MD, + llm_provider=payload.llm_provider, + llm_model=payload.llm_model, + api_key=payload.api_key, + api_base=payload.api_base, + temperature=payload.temperature, + top_p=payload.top_p, + max_tokens=payload.max_tokens, + 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, + image_tag=payload.image_tag, + workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, payload.id), + ) + + session.add(bot) + session.commit() + session.refresh(bot) + _write_env_store(payload.id, _normalize_env_params(payload.env_params)) + _sync_workspace_channels(session, payload.id, channels_override=_normalize_initial_channels(payload.id, payload.channels)) + return bot + + +@app.get("/api/bots", response_model=List[BotInstance]) +def list_bots(session: Session = Depends(get_session)): + return session.exec(select(BotInstance)).all() + + +@app.put("/api/bots/{bot_id}", response_model=BotInstance) +def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + 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}") + + tools_config = update_data.pop("tools_config", None) if isinstance(update_data, dict) else None + if tools_config is not None: + bot.tools_config_json = json.dumps(_normalize_tools_config(tools_config), ensure_ascii=False) + env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None + + for key, value in update_data.items(): + setattr(bot, key, value) + + session.add(bot) + session.commit() + session.refresh(bot) + if env_params is not None: + _write_env_store(bot_id, _normalize_env_params(env_params)) + _sync_workspace_channels(session, bot_id) + return bot + + +@app.post("/api/bots/{bot_id}/start") +async def start_bot(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") + _sync_workspace_channels(session, bot_id) + + 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, + ) + if success: + bot.docker_status = "RUNNING" + session.add(bot) + session.commit() + return {"status": "started"} + + raise HTTPException(status_code=500, detail=f"Failed to start container with image {bot.image_tag}") + + +@app.post("/api/bots/{bot_id}/stop") +def stop_bot(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") + + docker_manager.stop_bot(bot_id) + bot.docker_status = "STOPPED" + session.add(bot) + session.commit() + return {"status": "stopped"} + + +@app.post("/api/bots/{bot_id}/deactivate") +def deactivate_bot(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") + + docker_manager.stop_bot(bot_id) + bot.docker_status = "STOPPED" + session.add(bot) + session.commit() + return {"status": "deactivated"} + + +@app.delete("/api/bots/{bot_id}") +def delete_bot(bot_id: str, 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) + + messages = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all() + for row in messages: + session.delete(row) + + 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) + + return {"status": "deleted", "workspace_deleted": bool(delete_workspace)} + + +@app.get("/api/bots/{bot_id}/channels") +def list_bot_channels(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") + return _get_bot_channels_from_config(bot) + + +@app.get("/api/bots/{bot_id}/skills") +def list_bot_skills(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") + return _list_workspace_skills(bot_id) + + +@app.get("/api/bots/{bot_id}/tools-config") +def get_bot_tools_config(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") + return { + "bot_id": bot_id, + "tools_config": _parse_tools_config(bot.tools_config_json), + } + + +@app.put("/api/bots/{bot_id}/tools-config") +def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + normalized = _normalize_tools_config(payload.tools_config) + bot.tools_config_json = json.dumps(normalized, ensure_ascii=False) + session.add(bot) + session.commit() + session.refresh(bot) + + _sync_workspace_channels(session, bot_id) + return { + "status": "updated", + "bot_id": bot_id, + "tools_config": normalized, + } + + +@app.get("/api/bots/{bot_id}/env-params") +def get_bot_env_params(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") + return { + "bot_id": bot_id, + "env_params": _read_env_store(bot_id), + } + + +@app.put("/api/bots/{bot_id}/env-params") +def update_bot_env_params(bot_id: str, payload: BotEnvParamsUpdateRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + normalized = _normalize_env_params(payload.env_params) + _write_env_store(bot_id, normalized) + return { + "status": "updated", + "bot_id": bot_id, + "env_params": normalized, + "restart_required": True, + } + + +@app.post("/api/bots/{bot_id}/skills/upload") +async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + filename = str(file.filename or "").strip() + if not filename.lower().endswith(".zip"): + raise HTTPException(status_code=400, detail="Only .zip skill package is supported") + + 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)) + except Exception: + 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") + + 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: + 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) + + if not installed: + raise HTTPException(status_code=400, detail="No skill entries installed from zip") + + return { + "status": "installed", + "bot_id": bot_id, + "installed": installed, + "skills": _list_workspace_skills(bot_id), + } + + +@app.delete("/api/bots/{bot_id}/skills/{skill_name}") +def delete_bot_skill(bot_id: str, skill_name: str, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + name = str(skill_name or "").strip() + if not _is_valid_top_level_skill_name(name): + raise HTTPException(status_code=400, detail="Invalid skill name") + root = _skills_root(bot_id) + target = os.path.abspath(os.path.join(root, name)) + if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root): + raise HTTPException(status_code=400, detail="Invalid skill path") + if not os.path.exists(target): + raise HTTPException(status_code=404, detail="Skill not found in workspace") + if os.path.isdir(target): + shutil.rmtree(target, ignore_errors=False) + else: + os.remove(target) + return {"status": "deleted", "bot_id": bot_id, "skill": name} + + +@app.post("/api/bots/{bot_id}/channels") +def create_bot_channel(bot_id: str, payload: ChannelConfigRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + ctype = (payload.channel_type or "").strip().lower() + if not ctype: + raise HTTPException(status_code=400, detail="channel_type is required") + if ctype == "dashboard": + raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be created manually") + current_rows = _get_bot_channels_from_config(bot) + if any(str(row.get("channel_type") or "").lower() == ctype for row in current_rows): + raise HTTPException(status_code=400, detail=f"Channel already exists: {ctype}") + + new_row = { + "id": ctype, + "bot_id": bot_id, + "channel_type": ctype, + "external_app_id": (payload.external_app_id or "").strip() or f"{ctype}-{bot_id}", + "app_secret": (payload.app_secret or "").strip(), + "internal_port": max(1, min(int(payload.internal_port or 8080), 65535)), + "is_active": bool(payload.is_active), + "extra_config": _normalize_channel_extra(payload.extra_config), + "locked": False, + } + + config_data = _read_bot_config(bot_id) + channels_cfg = config_data.get("channels") + if not isinstance(channels_cfg, dict): + channels_cfg = {} + 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) + return new_row + + +@app.put("/api/bots/{bot_id}/channels/{channel_id}") +def update_bot_channel( + bot_id: str, + channel_id: str, + payload: ChannelConfigUpdateRequest, + session: Session = Depends(get_session), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + channel_key = str(channel_id or "").strip().lower() + rows = _get_bot_channels_from_config(bot) + row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None) + if not row: + raise HTTPException(status_code=404, detail="Channel not found") + + update_data = payload.model_dump(exclude_unset=True) + existing_type = str(row.get("channel_type") or "").strip().lower() + new_type = existing_type + if "channel_type" in update_data and update_data["channel_type"] is not None: + new_type = str(update_data["channel_type"]).strip().lower() + if not new_type: + raise HTTPException(status_code=400, detail="channel_type cannot be empty") + if existing_type == "dashboard" and new_type != "dashboard": + raise HTTPException(status_code=400, detail="dashboard channel type cannot be changed") + if new_type != existing_type and any(str(r.get("channel_type") or "").lower() == new_type for r in rows): + raise HTTPException(status_code=400, detail=f"Channel already exists: {new_type}") + + if "external_app_id" in update_data and update_data["external_app_id"] is not None: + row["external_app_id"] = str(update_data["external_app_id"]).strip() + if "app_secret" in update_data and update_data["app_secret"] is not None: + row["app_secret"] = str(update_data["app_secret"]).strip() + if "internal_port" in update_data and update_data["internal_port"] is not None: + row["internal_port"] = max(1, min(int(update_data["internal_port"]), 65535)) + if "is_active" in update_data and update_data["is_active"] is not None: + next_active = bool(update_data["is_active"]) + if existing_type == "dashboard" and not next_active: + raise HTTPException(status_code=400, detail="dashboard channel must remain enabled") + row["is_active"] = next_active + if "extra_config" in update_data: + row["extra_config"] = _normalize_channel_extra(update_data.get("extra_config")) + row["channel_type"] = new_type + row["id"] = new_type + row["locked"] = new_type == "dashboard" + + if new_type == "dashboard": + extra = _normalize_channel_extra(row.get("extra_config")) + bot.send_progress = bool(extra.get("sendProgress", bot.send_progress)) + bot.send_tool_hints = bool(extra.get("sendToolHints", bot.send_tool_hints)) + session.add(bot) + + config_data = _read_bot_config(bot_id) + channels_cfg = config_data.get("channels") + if not isinstance(channels_cfg, dict): + channels_cfg = {} + config_data["channels"] = channels_cfg + channels_cfg["sendProgress"] = bool(bot.send_progress) + channels_cfg["sendToolHints"] = bool(bot.send_tool_hints) + if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type: + channels_cfg.pop(existing_type, None) + if new_type != "dashboard": + channels_cfg[new_type] = _channel_api_to_cfg(row) + _write_bot_config(bot_id, config_data) + session.commit() + _sync_workspace_channels(session, bot_id) + return row + + +@app.delete("/api/bots/{bot_id}/channels/{channel_id}") +def delete_bot_channel(bot_id: str, channel_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") + + channel_key = str(channel_id or "").strip().lower() + rows = _get_bot_channels_from_config(bot) + row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None) + if not row: + raise HTTPException(status_code=404, detail="Channel not found") + if str(row.get("channel_type") or "").lower() == "dashboard": + raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted") + + config_data = _read_bot_config(bot_id) + channels_cfg = config_data.get("channels") + if not isinstance(channels_cfg, dict): + channels_cfg = {} + config_data["channels"] = channels_cfg + 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) + return {"status": "deleted"} + + +@app.post("/api/bots/{bot_id}/command") +def send_command(bot_id: str, payload: CommandRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + 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) + + 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}" + ) + + if display_command or checked_attachments: + _persist_runtime_packet( + bot_id, + {"type": "USER_COMMAND", "channel": "dashboard", "text": display_command, "media": checked_attachments}, + ) + + loop = getattr(app.state, "main_loop", None) + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe( + manager.broadcast( + bot_id, + { + "type": "USER_COMMAND", + "channel": "dashboard", + "text": display_command, + "media": checked_attachments, + }, + ), + loop, + ) + + success = docker_manager.send_command(bot_id, delivery_command, media=checked_attachments) + if not success: + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe( + manager.broadcast( + bot_id, + { + "type": "AGENT_STATE", + "channel": "dashboard", + "payload": { + "state": "ERROR", + "action_msg": "command delivery failed", + }, + }, + ), + loop, + ) + raise HTTPException(status_code=502, detail="Failed to deliver command to bot dashboard channel") + return {"success": True} + + +@app.get("/api/bots/{bot_id}/messages") +def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + safe_limit = max(1, min(int(limit), 500)) + rows = session.exec( + select(BotMessage) + .where(BotMessage.bot_id == bot_id) + .order_by(BotMessage.created_at.desc(), BotMessage.id.desc()) + .limit(safe_limit) + ).all() + ordered = list(reversed(rows)) + return [ + { + "id": row.id, + "bot_id": row.bot_id, + "role": row.role, + "text": row.text, + "media": _parse_message_media(bot_id, getattr(row, "media_json", None)), + "ts": int(row.created_at.timestamp() * 1000), + } + for row in ordered + ] + + +@app.delete("/api/bots/{bot_id}/messages") +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 + bot.last_action = "" + bot.current_state = "IDLE" + bot.updated_at = datetime.utcnow() + session.add(bot) + session.commit() + return {"bot_id": bot_id, "deleted": deleted} + + +@app.get("/api/bots/{bot_id}/logs") +def get_bot_logs(bot_id: str, tail: int = 300, 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)} + + +@app.get("/api/bots/{bot_id}/workspace/tree") +def get_workspace_tree(bot_id: str, path: Optional[str] = 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(target, root), + } + + +@app.get("/api/bots/{bot_id}/workspace/file") +def read_workspace_file( + bot_id: str, + path: str, + max_bytes: int = 200000, + 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("\\", "/") + + return { + "bot_id": bot_id, + "path": rel_path, + "size": os.path.getsize(target), + "is_markdown": rel_path.lower().endswith(".md"), + "truncated": truncated, + "content": text_body, + } + + +@app.get("/api/bots/{bot_id}/cron/jobs") +def list_cron_jobs(bot_id: str, include_disabled: 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") + store = _read_cron_store(bot_id) + rows = [] + for row in store.get("jobs", []): + if not isinstance(row, dict): + continue + enabled = bool(row.get("enabled", True)) + if not include_disabled and not enabled: + continue + rows.append(row) + rows.sort(key=lambda v: int(((v.get("state") or {}).get("nextRunAtMs")) or 2**62)) + return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows} + + +@app.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop") +def stop_cron_job(bot_id: str, job_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") + store = _read_cron_store(bot_id) + jobs = store.get("jobs", []) + if not isinstance(jobs, list): + jobs = [] + found = None + for row in jobs: + if isinstance(row, dict) and str(row.get("id")) == job_id: + found = row + break + if not found: + raise HTTPException(status_code=404, detail="Cron job not found") + found["enabled"] = False + found["updatedAtMs"] = int(datetime.utcnow().timestamp() * 1000) + _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs}) + return {"status": "stopped", "job_id": job_id} + + +@app.delete("/api/bots/{bot_id}/cron/jobs/{job_id}") +def delete_cron_job(bot_id: str, job_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") + store = _read_cron_store(bot_id) + jobs = store.get("jobs", []) + if not isinstance(jobs, list): + jobs = [] + kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)] + if len(kept) == len(jobs): + raise HTTPException(status_code=404, detail="Cron job not found") + _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept}) + return {"status": "deleted", "job_id": job_id} + + +@app.get("/api/bots/{bot_id}/workspace/download") +def download_workspace_file( + bot_id: str, + path: str, + download: bool = False, + session: Session = Depends(get_session), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + _, target = _resolve_workspace_path(bot_id, path) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="workspace file not found") + media_type, _ = mimetypes.guess_type(target) + if download: + return FileResponse(target, filename=os.path.basename(target), media_type=media_type) + return FileResponse(target, media_type=media_type) + + +@app.post("/api/bots/{bot_id}/workspace/upload") +async def upload_workspace_files( + bot_id: str, + files: List[UploadFile] = File(...), + path: Optional[str] = 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") + + 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" + + 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) + + content = await upload.read() + with open(abs_path, "wb") as f: + f.write(content) + + rel = os.path.relpath(abs_path, root).replace("\\", "/") + rows.append({"name": name, "path": rel, "size": len(content)}) + + return {"bot_id": bot_id, "files": rows} + + +@app.websocket("/ws/monitor/{bot_id}") +async def websocket_endpoint(websocket: WebSocket, bot_id: str): + await manager.connect(bot_id, websocket) + docker_manager.ensure_monitor(bot_id, docker_callback) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(bot_id, websocket) + + +def _main_server_options() -> tuple[str, int, 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")) + except Exception: + 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 + + +if __name__ == "__main__": + import uvicorn + + host, port, reload_flag = _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) + else: + uvicorn.run(app, host=host, port=port) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/bot.py b/backend/models/bot.py new file mode 100644 index 0000000..401c358 --- /dev/null +++ b/backend/models/bot.py @@ -0,0 +1,50 @@ +from typing import Optional +from sqlmodel import Field, SQLModel +from datetime import datetime + +class BotInstance(SQLModel, table=True): + id: str = Field(primary_key=True) + name: str + avatar_model: str = Field(default="base") + avatar_skin: Optional[str] = Field(default="blue_suit") + system_prompt: Optional[str] = Field(default=None) + soul_md: Optional[str] = Field(default=None) + agents_md: Optional[str] = Field(default=None) + user_md: Optional[str] = Field(default=None) + tools_md: Optional[str] = Field(default=None) + tools_config_json: Optional[str] = Field(default=None) # JSON string for runtime tools config + identity_md: Optional[str] = Field(default=None) + llm_provider: str + llm_model: str + api_key: str # Encrypted + api_base: Optional[str] = Field(default=None) + temperature: float = Field(default=0.2) + top_p: float = Field(default=1.0) + max_tokens: int = Field(default=8192) + presence_penalty: float = Field(default=0.0) + frequency_penalty: float = Field(default=0.0) + send_progress: bool = Field(default=False) + send_tool_hints: bool = Field(default=False) + workspace_dir: str = Field(unique=True) + docker_status: str = Field(default="STOPPED", index=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") # 记录该机器人使用的镜像版本 + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + +class BotMessage(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + bot_id: str = Field(foreign_key="botinstance.id", index=True) + role: str = Field(index=True) # user | assistant | system + text: str + media_json: Optional[str] = Field(default=None) # JSON string list of workspace-relative file paths + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) + +class NanobotImage(SQLModel, table=True): + tag: str = Field(primary_key=True) # e.g., nanobot-base:v0.1.4 + image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID + version: str # e.g., 0.1.4 + status: str = Field(default="READY") # READY, BUILDING, ERROR + source_dir: Optional[str] = Field(default=None) # 本地来源标识(如 manual) + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..bbacb10 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.110.0 +uvicorn==0.27.1 +docker==7.0.0 +sqlmodel==0.0.16 +pydantic==2.6.3 +psycopg[binary]==3.2.1 +pymysql==1.1.1 +python-dotenv==1.0.1 +httpx==0.27.0 +websockets==12.0 +python-multipart==0.0.9 +cryptography==42.0.5 +jinja2==3.1.3 +watchfiles==0.21.0 +urllib3==1.26.18 +requests==2.31.0 diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot-images/Dashboard.Dockerfile b/bot-images/Dashboard.Dockerfile new file mode 100644 index 0000000..1e15d17 --- /dev/null +++ b/bot-images/Dashboard.Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PYTHONIOENCODING=utf-8 + +# 1. 替换 apt 国内源 +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \ + sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list + +# 2. 安装基础依赖 +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 3. 安装 aiohttp 和基础 python 工具 +RUN python -m pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --upgrade \ + pip setuptools wheel aiohttp + +WORKDIR /app +# 这一步会把您修改好的 nanobot/channels/dashboard.py 一起拷进去 +COPY . /app + +# 4. 安装 nanobot +RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ . + +WORKDIR /root +# 官方 gateway 模式,现在它会自动加载您的 DashboardChannel +CMD ["nanobot", "gateway"] diff --git a/design/architecture.md b/design/architecture.md new file mode 100644 index 0000000..951c26c --- /dev/null +++ b/design/architecture.md @@ -0,0 +1,64 @@ +# Dashboard Nanobot 架构设计文档(当前实现) + +## 1. 设计原则 + +- 控制平面与数据平面分离。 +- 引擎零侵入:不修改 nanobot 源码,仅通过 workspace 与容器管理接入。 +- 镜像显式登记:系统不自动构建,不扫描 `engines/`,只使用 Docker 本地镜像 + DB 注册。 +- 可观测性优先:通过容器日志流解析状态并推送到 WebSocket。 + +## 2. 核心组件 + +### 2.1 Frontend(React + Zustand) + +- `ImageFactoryModule`:镜像登记与状态展示。 +- `BotWizardModule`:引导创建与模型测试。 +- `BotDashboardModule`:2D 运维控制台(无 3D),采用标准 LLM 聊天气泡视图 + 对话流中的思考动画 + workspace 文件浏览器。 +- 全局支持 light/dark 与 中英文切换。 + +### 2.2 Backend(FastAPI + SQLModel + docker-py) + +- 镜像 API:`/api/images`、`/api/docker-images`、`/api/images/register` +- Bot 生命周期 API:`/api/bots`、`/api/bots/{id}/start|stop|command` +- Workspace API:`/api/bots/{id}/workspace/tree`、`/api/bots/{id}/workspace/file` +- Provider 测试 API:`/api/providers/test` +- 监控通道:`/ws/monitor/{bot_id}` + +### 2.3 Workspace 配置生成器 + +`BotConfigManager.update_workspace` 负责: + +- 写入 `.nanobot/config.json` +- 写入 5 个 bootstrap 文件: + - `AGENTS.md` + - `SOUL.md` + - `USER.md` + - `TOOLS.md` + - `IDENTITY.md` + +## 3. 关键数据流 + +### 3.1 镜像流 + +1. 用户手动构建镜像(命令行) +2. 前端读取 `/api/docker-images` +3. 用户点击“加入数据库” -> `/api/images/register` +4. 后续创建向导只消费 `/api/images` 且筛选 `READY` + +### 3.2 创建流 + +1. 前端提交 Bot 创建参数(镜像、provider、模型参数、5 个 md 文件) +2. 后端校验镜像可用并写入 `botinstance` +3. 启动时生成 workspace 文件并拉起容器 + +### 3.3 运维流 + +1. 前端轮询 `/api/bots` 获取实例列表 +2. 对运行中 bot 建立 WebSocket 连接 +3. 后端解析日志,推送 `AGENT_STATE` 与 `RAW_LOG` + +## 4. 当前约束 + +- Provider 测试接口当前只做 `dashscope/openrouter`。 +- Skills 采用 workspace 文件系统模式管理,不再使用数据库注册表。 +- Bot 状态来自日志与容器状态,不依赖侵入式埋点。 diff --git a/design/dashboard-nanobot.md b/design/dashboard-nanobot.md new file mode 100644 index 0000000..4d59116 --- /dev/null +++ b/design/dashboard-nanobot.md @@ -0,0 +1,131 @@ +# Dashboard Nanobot 技术规范书(当前实现) + +本文档描述当前代码已落地的 Dashboard Nanobot 控制平面规范,覆盖前端流程、后端接口、配置文件生成和运行约束。 + +## 1. 产品目标 + +系统围绕三步主流程: + +1. 镜像工厂:仅管理 Docker 本地镜像与数据库登记状态。 +2. 引导创建:基于 READY 镜像创建 Bot,填写模型参数与代理文件。 +3. 运维 Dashboard:2D 控制台管理 Bot 对话、状态、日志与启停。 + +## 2. 前端模块规范 + +### 2.1 镜像工厂(Image Factory) + +- 页面显示两类列表: + - 数据库已登记镜像(来源 `/api/images`) + - Docker 本地镜像(来源 `/api/docker-images`) +- 系统不再自动扫描 `engines/`。 +- 仅通过“加入数据库”动作调用 `/api/images/register` 完成登记。 +- 创建向导仅允许使用 `status=READY` 的已登记镜像。 + +### 2.2 引导创建(Bot Wizard) + +- 采用 4 步流程: + 1. 选择 READY 镜像 + 2. 模型与参数 + 3. 代理文件配置 + 4. 确认创建 +- Provider 支持:`dashscope`、`openrouter`、`openai`、`deepseek`。 +- 提供模型连通性测试按钮(调用 `/api/providers/test`)。 +- 标准模型参数: + - `temperature` + - `top_p` + - `max_tokens` +- 参数编辑交互: + - `temperature`、`top_p` 使用滑轨 + - `max_tokens` 使用数字输入 +- 代理文件配置项(全部可编辑并持久化): + - `AGENTS.md` + - `SOUL.md` + - `USER.md` + - `TOOLS.md` + - `IDENTITY.md` +- 渠道配置: + - 创建向导只配置“可选外部渠道”(飞书/QQ 等) + - `dashboard` 渠道为内置渠道,不在向导中生成渠道块 + - 全局 `sendProgress/sendToolHints` 作为独立开关提交 + +### 2.3 运维 Dashboard(2D) + +- 不使用 3D 场景。 +- 三栏布局: + - 左侧:Bot 列表、镜像、启停操作 + - 中间:标准 LLM 对话区(Bot 左气泡 + 用户右气泡);Bot 处于 THINKING 时在对话流中显示思考动画 + - 右侧:运行遥测、最后反馈、workspace 文件浏览器(双击进目录/返回上级/双击文件弹窗预览) +- 支持实时状态更新(WebSocket `/ws/monitor/{bot_id}`)。 +- Skills 管理入口仅在运维面板(更多菜单): + - 列出当前 Bot 的 `workspace/skills` 内容 + - 支持删除技能目录/文件 + - 支持上传 zip 并安全解压到 `workspace/skills` +- workspace 可视化接口: + - `/api/bots/{bot_id}/workspace/tree` + - `/api/bots/{bot_id}/workspace/file` + +### 2.4 主题与语言 + +- 支持 `light/dark` 切换。 +- 支持 `中文/English` 切换。 +- 切换状态保存在浏览器 `localStorage`。 + +## 3. 后端行为规范 + +### 3.1 镜像登记策略 + +- `/api/images`:只返回数据库登记镜像,并和 Docker 实时对账更新状态。 +- `/api/docker-images`:列出本地 `nanobot-base:*`。 +- `/api/images/register`:将本地镜像显式写入登记表。 + +### 3.2 Bot 创建与启动 + +- `/api/bots` 创建时必须满足: + - 镜像已登记 + - 镜像状态 `READY` + - 本机 Docker 存在该镜像 +- `/api/bots/{id}/start` 启动前写入 workspace: + - `.nanobot/config.json` + - `.nanobot/workspace/AGENTS.md` + - `.nanobot/workspace/SOUL.md` + - `.nanobot/workspace/USER.md` + - `.nanobot/workspace/TOOLS.md` + - `.nanobot/workspace/IDENTITY.md` + +### 3.3 Provider 测试接口 + +- `/api/providers/test` 当前支持: + - `openrouter`(`/models`) + - `dashscope`(`/models`) +- 返回模型预览列表与匹配提示。 + +### 3.4 Skills 文件系统接口 + +- `GET /api/bots/{bot_id}/skills`:列出 `workspace/skills` 一级技能项。 +- `POST /api/bots/{bot_id}/skills/upload`:上传 zip,做路径安全校验后解压安装。 +- `DELETE /api/bots/{bot_id}/skills/{skill_name}`:删除指定技能目录/文件。 + +## 4. Workspace 规范 + +宿主机目录:`workspace/bots/{bot_id}/.nanobot/` + +```text +.nanobot/ +├── config.json +└── workspace/ + ├── AGENTS.md + ├── SOUL.md + ├── USER.md + ├── TOOLS.md + ├── IDENTITY.md + ├── memory/ + └── skills/ +``` + +`skills/` 目录下每个一级目录(或单文件)视为一个可管理技能单元。 + +## 5. 与 nanobot 的兼容原则 + +- 不侵入 nanobot 源码。 +- 控制平面通过文件与容器生命周期管理进行编排。 +- Bootstrap 文件命名与 `nanobot.agent.context.ContextBuilder.BOOTSTRAP_FILES` 对齐。 diff --git a/design/database.md b/design/database.md new file mode 100644 index 0000000..3307ac4 --- /dev/null +++ b/design/database.md @@ -0,0 +1,130 @@ +# Dashboard Nanobot 数据库设计文档(同步到当前代码) + +数据库默认使用 SQLite:`data/nanobot_dashboard.db`。 + +## 1. ERD + +```mermaid +erDiagram + BOTINSTANCE ||--o{ CHANNELROUTE : "路由" + BOTINSTANCE ||--o{ BOTMESSAGE : "消息" + NANOBOTIMAGE ||--o{ BOTINSTANCE : "被引用" + + 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 + string current_state + text last_action + datetime created_at + datetime updated_at + } + + BOTMESSAGE { + int id PK + string bot_id FK + string role + text text + text media_json + datetime created_at + } + + NANOBOTIMAGE { + string tag PK + string image_id + string version + string status + 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.1 `botinstance` + +存储机器人实例核心配置与运行参数。 + +- 运行与镜像:`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` + +### 2.2 `botmessage` + +保存 Dashboard 渠道持久化对话消息。 + +- `role`:`user | assistant` +- `text`:消息正文 +- `media_json`:附件路径列表(JSON 字符串) + +### 2.3 `nanobotimage` + +镜像登记表(只记录显式登记镜像)。 + +- `tag`: 如 `nanobot-base:v0.1.4` +- `status`: `READY | BUILDING | ERROR | UNKNOWN` +- `source_dir`: 当前手工模式通常为 `manual` + +### 2.4 `channelroute` + +渠道路由映射表。 + +- `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 文件,确保配置一致性。 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d8202b3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,75 @@ +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + args: + PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-python:3.12-slim} + PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple} + PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-} + image: dashboard-nanobot/backend:${BACKEND_IMAGE_TAG:-latest} + container_name: dashboard-nanobot-backend + restart: unless-stopped + environment: + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + APP_RELOAD: "false" + DATABASE_ECHO: "false" + DATA_ROOT: ${HOST_DATA_ROOT} + BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} + DATABASE_URL: ${DATABASE_URL:-} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${HOST_DATA_ROOT}:${HOST_DATA_ROOT} + - ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT} + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3).read()"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + networks: + - nanobot_net + + nginx: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-node:20-alpine} + NGINX_BASE_IMAGE: ${NGINX_BASE_IMAGE:-nginx:alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/} + VITE_API_BASE: /api + VITE_WS_BASE: /ws/monitor + image: dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG:-latest} + container_name: dashboard-nanobot-nginx + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${NGINX_PORT}:80" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + networks: + - nanobot_net + +networks: + nanobot_net: + driver: bridge diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..f6dddf3 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Backend API entry +VITE_API_BASE=http://localhost:8000/api + +# Backend WebSocket entry +VITE_WS_BASE=ws://localhost:8000/ws/monitor diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b50664c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.env.* +!.env.example + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..84ea40c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +ARG NODE_BASE_IMAGE=node:20-alpine +FROM ${NODE_BASE_IMAGE} AS build + +WORKDIR /app + +ARG NPM_REGISTRY=https://registry.npmjs.org/ + +COPY package*.json ./ +RUN npm config set registry "${NPM_REGISTRY}" \ + && npm ci + +COPY . . + +ARG VITE_API_BASE=/api +ARG VITE_WS_BASE=/ws/monitor +ENV VITE_API_BASE=${VITE_API_BASE} +ENV VITE_WS_BASE=${VITE_WS_BASE} + +RUN npm run build + +ARG NGINX_BASE_IMAGE=nginx:alpine +FROM ${NGINX_BASE_IMAGE} + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf new file mode 100644 index 0000000..62b92ed --- /dev/null +++ b/frontend/docker/nginx.conf @@ -0,0 +1,37 @@ +upstream nanobot_backend { + server backend:8000; +} + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://nanobot_backend/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ws/monitor/ { + proxy_pass http://nanobot_backend/ws/monitor/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..106c884 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Dashboard Nanobot + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b681fea --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@types/three": "^0.183.1", + "axios": "^1.13.5", + "clsx": "^2.1.1", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.5.0", + "three": "^0.183.1", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/app-bot-icon.svg b/frontend/public/app-bot-icon.svg new file mode 100644 index 0000000..caac460 --- /dev/null +++ b/frontend/public/app-bot-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/nanobot-logo.png b/frontend/public/nanobot-logo.png new file mode 100644 index 0000000..9493813 Binary files /dev/null and b/frontend/public/nanobot-logo.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..75d71cc --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,1048 @@ +:root, +.app-shell[data-theme='dark'] { + --bg: #050d1f; + --bg-2: #0b2248; + --panel: #0f203f; + --panel-soft: #142a52; + --line: #3d5685; + --text: #f2f7ff; + --title: #d7e5ff; + --subtitle: #c1d1ee; + --muted: #9ab1db; + --icon: #d9e8ff; + --icon-muted: #b9ccef; + --brand: #5b95ff; + --brand-soft: #284d8f; + --ok: #25c88a; + --warn: #e2b54a; + --err: #eb6a6a; + --shadow: 0 18px 45px rgba(0, 0, 0, 0.45); +} + +.app-shell[data-theme='light'] { + --bg: #edf2fb; + --bg-2: #d3e1ff; + --panel: #ffffff; + --panel-soft: #f3f7ff; + --line: #b7c7e6; + --text: #10244b; + --title: #0c2149; + --subtitle: #294675; + --muted: #456092; + --icon: #16386f; + --icon-muted: #385b91; + --brand: #2f69e2; + --brand-soft: #d7e6ff; + --ok: #11a56f; + --warn: #c68b14; + --err: #d14b4b; + --shadow: 0 10px 30px rgba(45, 77, 143, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--text); + background-color: var(--bg); + background: + radial-gradient(1200px 800px at -12% -20%, var(--bg-2) 0%, transparent 60%), + radial-gradient(900px 600px at 110% 8%, var(--bg-2) 0%, transparent 62%), + linear-gradient(165deg, var(--bg) 0%, color-mix(in oklab, var(--bg) 75%, #000 25%) 100%); + font-family: 'Avenir Next', 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif; +} + +.app-shell { + min-height: 100vh; + padding: 18px; +} + +.app-frame { + height: calc(100vh - 36px); + display: grid; + grid-template-rows: auto 1fr; + gap: 14px; +} + +.app-header { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px 16px; + box-shadow: var(--shadow); + backdrop-filter: blur(2px); +} + +.app-header-top { + align-items: flex-start; +} + +.app-title { + display: flex; + align-items: center; + gap: 10px; +} + +.app-title-icon { + width: 22px; + height: 22px; + object-fit: contain; +} + +.app-title svg { + color: var(--icon); + stroke-width: 2.2; +} + +.app-title h1 { + margin: 0; + font-size: 20px; + font-weight: 800; + color: var(--title); +} + +.app-title p { + margin: 2px 0 0; + color: var(--subtitle); + font-size: 12px; +} + +.global-switches { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.switch-compact { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 5px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--panel-soft); +} + +.switch-label { + font-size: 12px; + color: var(--muted); + margin-left: 6px; + margin-right: 2px; +} + +.switch-btn { + border: 1px solid transparent; + border-radius: 999px; + min-width: 34px; + height: 30px; + padding: 0 8px; + background: transparent; + color: var(--icon-muted); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.switch-btn svg { + color: currentColor; + stroke-width: 2.2; +} + +.switch-btn-lang { + min-width: 40px; + padding: 0 10px; + gap: 0; + font-size: 12px; +} + +.switch-btn.active { + border-color: color-mix(in oklab, var(--brand) 60%, var(--line) 40%); + background: color-mix(in oklab, var(--brand) 20%, transparent); + color: var(--icon); +} + +.main-stage { + min-height: 0; + height: 100%; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px; + box-shadow: var(--shadow); +} + +.panel h2 { + margin: 0; + font-size: 19px; + color: var(--title); +} + +.panel-desc { + margin: 4px 0 0; + color: var(--subtitle); + font-size: 13px; + font-weight: 600; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + height: 100%; +} + +.grid-ops { + display: grid; + grid-template-columns: 320px 1fr 360px; + gap: 12px; + height: 100%; +} + +.grid-ops.grid-ops-compact { + grid-template-columns: minmax(0, 1fr) minmax(280px, 420px); +} + +.stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.input, +.select, +.textarea { + width: 100%; + border: 1px solid color-mix(in oklab, var(--line) 70%, var(--text) 8%); + background: var(--panel-soft); + color: var(--text); + border-radius: 10px; + padding: 9px 10px; + font-size: 13px; + outline: none; +} + +.app-shell[data-theme='light'] .input, +.app-shell[data-theme='light'] .select, +.app-shell[data-theme='light'] .textarea { + background: #f8fbff; +} + +.textarea { + resize: vertical; + min-height: 110px; +} + +.md-area { + min-height: 160px; + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.field-label { + color: var(--subtitle); + font-size: 12px; +} + +.input:focus, +.select:focus, +.textarea:focus { + border-color: var(--brand); +} + +.input:disabled, +.select:disabled, +.textarea:disabled { + background: color-mix(in oklab, var(--panel-soft) 72%, var(--line) 28%); + color: var(--muted); + cursor: not-allowed; +} + +.btn { + border: 1px solid transparent; + border-radius: 10px; + padding: 9px 12px; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.btn-primary { + background: var(--brand); + color: #fff; +} + +.btn-primary svg { + color: #fff; +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.btn-secondary { + background: color-mix(in oklab, var(--panel-soft) 78%, var(--brand-soft) 22%); + border-color: var(--line); + color: var(--text); +} + +.btn-secondary svg { + color: var(--icon); + stroke-width: 2.1; +} + +.btn-success { + background: color-mix(in oklab, var(--ok) 20%, var(--panel-soft) 80%); + border-color: color-mix(in oklab, var(--ok) 46%, var(--line) 54%); + color: color-mix(in oklab, var(--text) 72%, white 28%); +} + +.btn-success svg { + color: currentColor; + stroke-width: 2.1; +} + +.btn-danger svg { + color: currentColor; + stroke-width: 2.1; +} + +.btn-danger { + background: color-mix(in oklab, var(--err) 18%, var(--panel-soft) 82%); + border-color: color-mix(in oklab, var(--err) 44%, var(--line) 56%); + color: color-mix(in oklab, var(--text) 70%, white 30%); +} + +.row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.card { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); + padding: 10px; +} + +.app-shell[data-theme='light'] .card { + background: #f7faff; +} + +.selectable { + transition: border-color 0.2s; +} + +.selectable:hover { + border-color: color-mix(in oklab, var(--brand) 50%, var(--line) 50%); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid; +} + +.badge-ok { + color: color-mix(in oklab, var(--ok) 66%, white 34%); + background: color-mix(in oklab, var(--ok) 22%, transparent); + border-color: color-mix(in oklab, var(--ok) 52%, transparent); +} + +.badge-warn { + color: color-mix(in oklab, var(--warn) 60%, white 40%); + background: color-mix(in oklab, var(--warn) 16%, transparent); + border-color: color-mix(in oklab, var(--warn) 42%, transparent); +} + +.badge-err { + color: color-mix(in oklab, var(--err) 60%, white 40%); + background: color-mix(in oklab, var(--err) 16%, transparent); + border-color: color-mix(in oklab, var(--err) 42%, transparent); +} + +.badge-unknown { + color: var(--muted); + background: color-mix(in oklab, var(--line) 46%, transparent); + border-color: var(--line); +} + +.list-scroll { + overflow: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th, +.table td { + border-bottom: 1px solid var(--line); + text-align: left; + padding: 8px; +} + +.table th { + color: var(--subtitle); + font-size: 12px; + font-weight: 700; +} + +.mono { + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.wizard-steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.wizard-shell { + background: + linear-gradient(160deg, color-mix(in oklab, var(--panel) 88%, var(--brand-soft) 12%) 0%, var(--panel) 100%); + min-height: 760px; +} + +.wizard-head { + padding: 2px 2px 4px; +} + +.wizard-steps-enhanced .wizard-step { + border-radius: 12px; + border-color: color-mix(in oklab, var(--line) 72%, transparent); + background: color-mix(in oklab, var(--panel-soft) 82%, transparent); + font-weight: 700; +} + +.wizard-steps-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.wizard-step { + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px; + font-size: 12px; + color: var(--muted); + background: color-mix(in oklab, var(--panel-soft) 80%, black 20%); +} + +.wizard-step.active { + color: var(--text); + border-color: color-mix(in oklab, var(--brand) 65%, var(--line) 35%); + background: color-mix(in oklab, var(--brand-soft) 56%, var(--panel-soft) 44%); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--brand) 45%, transparent); +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.log-view { + background: color-mix(in oklab, var(--panel-soft) 82%, black 18%); + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px; + height: 100%; + overflow: auto; + font-size: 12px; + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.log-line { + padding: 5px 0; + border-bottom: 1px solid color-mix(in oklab, var(--line) 66%, transparent); + color: var(--text); +} + +.ops-chat-panel { + min-width: 0; +} + +.ops-header-band { + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px; + background: color-mix(in oklab, var(--panel-soft) 88%, var(--brand-soft) 12%); +} + +.ops-log-view { + min-height: 420px; +} + +.bot-card { + margin-bottom: 8px; + cursor: pointer; +} + +.bot-card.selected { + border-color: var(--brand); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--brand) 45%, transparent); +} + +.bot-name { + font-weight: 700; +} + +.bot-id, +.bot-meta { + color: color-mix(in oklab, var(--text) 70%, var(--muted) 30%); + font-size: 12px; + font-weight: 600; +} + +.telemetry-card { + display: grid; + gap: 7px; + font-size: 13px; +} + +.section-mini-title { + margin: 0; + font-size: 13px; + color: var(--subtitle); + font-weight: 700; +} + +.chat-tabs { + display: flex; + gap: 8px; +} + +.chat-view { + min-height: 420px; + max-height: 62vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px; + background: var(--panel-soft); +} + +.chat-bubble { + margin-bottom: 8px; + padding: 8px 10px; + border-radius: 10px; + line-height: 1.45; + white-space: pre-wrap; + color: var(--text); + font-size: 14px; + font-weight: 600; +} + +.chat-bubble.assistant { + border: 1px solid color-mix(in oklab, var(--brand) 45%, var(--line) 55%); + background: color-mix(in oklab, var(--brand-soft) 36%, var(--panel-soft) 64%); +} + +.chat-bubble.user { + border: 1px solid color-mix(in oklab, var(--ok) 55%, var(--line) 45%); + background: color-mix(in oklab, var(--ok) 18%, var(--panel-soft) 82%); +} + +.chat-bubble.system { + border: 1px dashed color-mix(in oklab, var(--warn) 50%, var(--line) 50%); + color: var(--text); + background: color-mix(in oklab, var(--warn) 18%, var(--panel-soft) 82%); + font-size: 12px; + font-weight: 700; +} + +.telemetry-strong { + color: var(--text); +} + +.telemetry-strong .mono { + color: var(--text); +} + +.event-list { + display: grid; + gap: 8px; + max-height: 220px; + overflow: auto; +} + +.event-item { + display: grid; + grid-template-columns: 92px 1fr; + gap: 8px; + align-items: center; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: color-mix(in oklab, var(--panel-soft) 90%, var(--panel) 10%); +} + +.event-state { + font-size: 11px; + font-weight: 700; +} + +.event-thinking .event-state { + color: #6ea5ff; +} + +.event-tool_call .event-state { + color: #67d3b1; +} + +.event-success .event-state { + color: #53cf95; +} + +.event-error .event-state { + color: #ef6666; +} + +.dialog-status-strip { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 8px; + align-items: center; +} + +.state-chip { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 800; + color: var(--text); +} + +.state-running { + border-color: color-mix(in oklab, var(--ok) 60%, var(--line) 40%); + background: color-mix(in oklab, var(--ok) 22%, transparent); +} + +.state-active { + border-color: color-mix(in oklab, var(--brand) 65%, var(--line) 35%); + background: color-mix(in oklab, var(--brand) 18%, transparent); +} + +.state-last-action { + font-size: 13px; + color: var(--text); + font-weight: 700; +} + +.dialog-midstate-strip { + display: flex; + gap: 8px; + overflow: auto; +} + +.midstate-pill { + min-width: 240px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 8px; + display: grid; + gap: 4px; + color: var(--text); + background: var(--panel-soft); +} + +.midstate-pill .mono { + font-size: 11px; + font-weight: 800; +} + +.midstate-pill.state-thinking .mono { + color: #6ea5ff; +} + +.midstate-pill.state-tool_call .mono { + color: #67d3b1; +} + +.midstate-pill.state-success .mono { + color: #53cf95; +} + +.midstate-pill.state-error .mono { + color: #ef6666; +} + +.chat-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.chat-role { + font-size: 11px; + font-weight: 800; + color: color-mix(in oklab, var(--text) 85%, var(--muted) 15%); +} + +.chat-time { + font-size: 10px; + color: var(--muted); +} + +.agent-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.agent-tabs-vertical { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 150px; +} + +.wizard-agent-layout { + display: grid; + grid-template-columns: 170px 1fr; + gap: 10px; + min-height: 420px; +} + +.agent-tab { + border: 1px solid var(--line); + background: var(--panel-soft); + color: var(--text); + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; +} + +.agent-tab.active { + border-color: var(--brand); + background: color-mix(in oklab, var(--brand-soft) 54%, var(--panel-soft) 46%); +} + +.modal-mask { + position: fixed; + inset: 0; + background: rgba(2, 9, 24, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 90; +} + +.modal-card { + width: min(680px, 90vw); + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + color: var(--text); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); +} + +.modal-wide { + width: min(980px, 94vw); +} + +.app-modal-mask { + z-index: 120; +} + +.app-modal-card { + width: min(1280px, 96vw); + max-height: 94vh; +} + +.app-modal-body { + min-height: 0; + max-height: calc(94vh - 92px); + overflow: auto; +} + +.modal-card h3 { + margin: 0; + color: var(--title); + font-size: 22px; + font-weight: 800; +} + +.modal-title-row { + display: grid; + gap: 4px; +} + +.modal-sub { + color: var(--subtitle); + font-size: 12px; + font-weight: 600; +} + +.slider-row { + display: grid; + gap: 6px; +} + +.wizard-step2-grid { + gap: 10px; +} + +.wizard-step2-card { + gap: 8px; + border-radius: 14px; + padding: 12px; + background: color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%); +} + +.wizard-note-card { + border-style: dashed; +} + +.token-input-row { + align-items: start; +} + +.token-number-input { + max-width: 220px; +} + +.slider-row input[type='range'] { + width: 100%; +} + +.wizard-channel-list { + display: grid; + gap: 8px; +} + +.wizard-channel-card { + min-width: 0; + display: grid; + gap: 6px; +} + +.wizard-channel-compact { + padding: 10px; + border-radius: 10px; +} + +.wizard-dashboard-switches { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: center; +} + +.wizard-channel-summary { + display: grid; + gap: 8px; +} + +.wizard-icon-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.icon-btn { + width: 34px; + height: 34px; + padding: 0; + border-radius: 9px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.factory-kpi-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.kpi-card { + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in oklab, var(--panel-soft) 88%, transparent); + padding: 9px; +} + +.kpi-label { + font-size: 11px; + color: var(--subtitle); +} + +.kpi-value { + margin-top: 6px; + font-size: 22px; + font-weight: 800; +} + +/* Readability fallback rules (avoid low contrast when color-mix is limited). */ +.app-shell[data-theme='dark'] .panel { + background: #0f203f; + border-color: #3d5685; + color: #f2f7ff; +} + +.app-shell[data-theme='dark'] .card { + background: #142a52; + border-color: #3d5685; +} + +.app-shell[data-theme='dark'] .btn-secondary { + background: #1d3768; + border-color: #44639a; + color: #f2f7ff; +} + +.app-shell[data-theme='dark'] .btn-success { + background: #173f33; + border-color: #3ea486; + color: #d8fff2; +} + +.app-shell[data-theme='dark'] .btn-danger { + background: #3b1f2a; + border-color: #9d4f65; + color: #ffdce4; +} + +.app-shell[data-theme='light'] .panel { + background: #ffffff; + border-color: #b7c7e6; + color: #132a54; +} + +.app-shell[data-theme='light'] .card { + background: #f7faff; + border-color: #b7c7e6; + color: #132a54; +} + +.app-shell[data-theme='light'] .panel-desc, +.app-shell[data-theme='light'] .field-label, +.app-shell[data-theme='light'] .kicker, +.app-shell[data-theme='light'] .sub, +.app-shell[data-theme='light'] .chat-time, +.app-shell[data-theme='light'] .bot-id, +.app-shell[data-theme='light'] .bot-meta { + color: var(--subtitle); +} + +.app-shell[data-theme='light'] .btn-secondary { + background: #e9f1ff; + border-color: #b8cbee; + color: #163265; +} + +.app-shell[data-theme='light'] .btn-success { + background: #e6f8f1; + border-color: #9fd5bf; + color: #0f6b4e; +} + +.app-shell[data-theme='light'] .btn-danger { + background: #fdeeee; + border-color: #e0b0b0; + color: #8d2439; +} + +.app-shell[data-theme='light'] .input, +.app-shell[data-theme='light'] .select, +.app-shell[data-theme='light'] .textarea { + background: #ffffff; + border-color: #bccbe7; + color: #132a54; +} + +.app-shell[data-theme='light'] .input:disabled, +.app-shell[data-theme='light'] .select:disabled, +.app-shell[data-theme='light'] .textarea:disabled { + background: #edf2fb; + color: #5b6f97; +} + +.app-shell[data-theme='light'] .modal-card { + background: #ffffff; + border-color: #b7c7e6; + color: #132a54; +} + +.app-shell[data-theme='light'] .modal-card h3 { + color: var(--title); +} + +@media (max-width: 1400px) { + .grid-ops { + grid-template-columns: 280px 1fr 320px; + } + + .grid-ops.grid-ops-compact { + grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); + } +} + +@media (max-width: 1160px) { + .grid-2, + .grid-ops, + .wizard-steps, + .wizard-steps-4, + .factory-kpi-grid, + .summary-grid, + .wizard-agent-layout { + grid-template-columns: 1fr; + } + + .app-frame { + height: auto; + min-height: calc(100vh - 36px); + } + + .app-shell { + padding: 12px; + } + + .app-header-top { + flex-direction: column; + align-items: flex-start; + } + + .global-switches { + width: 100%; + justify-content: flex-start; + } + + .wizard-shell { + min-height: 640px; + } +} + +@media (max-width: 980px) { + .grid-ops.grid-ops-compact { + grid-template-columns: 1fr; + grid-template-rows: auto auto; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c5e091e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,135 @@ +import { useMemo, useState } from 'react'; +import { Boxes, MoonStar, Sparkles, SunMedium } from 'lucide-react'; +import { + useAppStore, +} from './store/appStore'; +import { useBotsSync } from './hooks/useBotsSync'; +import { ImageFactoryModule } from './modules/images/ImageFactoryModule'; +import { BotWizardModule } from './modules/onboarding/BotWizardModule'; +import { BotDashboardModule } from './modules/dashboard/BotDashboardModule'; +import { pickLocale } from './i18n'; +import { appZhCn } from './i18n/app.zh-cn'; +import { appEn } from './i18n/app.en'; +import './App.css'; + +function App() { + const { theme, setTheme, locale, setLocale } = useAppStore(); + const [showImageFactory, setShowImageFactory] = useState(false); + const [showCreateWizard, setShowCreateWizard] = useState(false); + useBotsSync(); + const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); + const urlView = useMemo(() => { + const params = new URLSearchParams(window.location.search); + const forcedBotId = + (params.get('botId') || params.get('bot_id') || params.get('id') || '').trim(); + const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase(); + const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw); + const compactMode = compactByFlag || forcedBotId.length > 0; + return { forcedBotId, compactMode }; + }, []); + + return ( +
+
+
+
+
+ Nanobot +
+

{t.title}

+
+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ +
+ setShowCreateWizard(true)} + onOpenImageFactory={() => setShowImageFactory(true)} + forcedBotId={urlView.forcedBotId || undefined} + compactMode={urlView.compactMode} + /> +
+
+ + {!urlView.compactMode && showImageFactory && ( +
setShowImageFactory(false)}> +
e.stopPropagation()}> +
+

{t.nav.images.title}

+ +
+
+ +
+
+
+ )} + + {!urlView.compactMode && showCreateWizard && ( +
setShowCreateWizard(false)}> +
e.stopPropagation()}> +
+

{t.nav.onboarding.title}

+ +
+
+ { + setShowCreateWizard(false); + }} + onGoDashboard={() => setShowCreateWizard(false)} + /> +
+
+
+ )} +
+ ); +} + +export default App; diff --git a/frontend/src/assets/nanobot-logo.png b/frontend/src/assets/nanobot-logo.png new file mode 100644 index 0000000..9493813 Binary files /dev/null and b/frontend/src/assets/nanobot-logo.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/BotManagementView.tsx b/frontend/src/components/BotManagementView.tsx new file mode 100644 index 0000000..99eb800 --- /dev/null +++ b/frontend/src/components/BotManagementView.tsx @@ -0,0 +1 @@ +export { ManagementModule as BotManagementView } from '../modules/management/ManagementModule'; diff --git a/frontend/src/components/CreateBotModal.tsx b/frontend/src/components/CreateBotModal.tsx new file mode 100644 index 0000000..8d1ba26 --- /dev/null +++ b/frontend/src/components/CreateBotModal.tsx @@ -0,0 +1 @@ +export { CreateBotModal } from '../modules/management/components/CreateBotModal'; diff --git a/frontend/src/components/KernelManagerModal.tsx b/frontend/src/components/KernelManagerModal.tsx new file mode 100644 index 0000000..f6f7489 --- /dev/null +++ b/frontend/src/components/KernelManagerModal.tsx @@ -0,0 +1 @@ +export { KernelManagerModal } from '../modules/management/components/KernelManagerModal'; diff --git a/frontend/src/components/VisualDeckView.tsx b/frontend/src/components/VisualDeckView.tsx new file mode 100644 index 0000000..dcfc25f --- /dev/null +++ b/frontend/src/components/VisualDeckView.tsx @@ -0,0 +1 @@ +export { DashboardModule as VisualDeckView } from '../modules/dashboard/DashboardModule'; diff --git a/frontend/src/components/VoxelBot.tsx b/frontend/src/components/VoxelBot.tsx new file mode 100644 index 0000000..d7954e5 --- /dev/null +++ b/frontend/src/components/VoxelBot.tsx @@ -0,0 +1 @@ +export { VoxelBot } from '../modules/dashboard/components/VoxelBot'; diff --git a/frontend/src/components/WorkingDeck.tsx b/frontend/src/components/WorkingDeck.tsx new file mode 100644 index 0000000..21e9e20 --- /dev/null +++ b/frontend/src/components/WorkingDeck.tsx @@ -0,0 +1 @@ +export { WorkingDeck } from '../modules/dashboard/components/WorkingDeck'; diff --git a/frontend/src/components/lucent/LucentPromptProvider.tsx b/frontend/src/components/lucent/LucentPromptProvider.tsx new file mode 100644 index 0000000..eb0d1b8 --- /dev/null +++ b/frontend/src/components/lucent/LucentPromptProvider.tsx @@ -0,0 +1,168 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'; +import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from 'lucide-react'; +import { useAppStore } from '../../store/appStore'; +import './lucent-prompt.css'; + +type PromptTone = 'info' | 'success' | 'warning' | 'error'; + +interface NotifyOptions { + title?: string; + tone?: PromptTone; + durationMs?: number; +} + +interface ConfirmOptions { + title?: string; + message: string; + tone?: PromptTone; + confirmText?: string; + cancelText?: string; +} + +interface ToastItem { + id: number; + title?: string; + message: string; + tone: PromptTone; +} + +interface ConfirmState { + title?: string; + message: string; + tone: PromptTone; + confirmText: string; + cancelText: string; + resolve: (value: boolean) => void; +} + +interface LucentPromptApi { + notify: (message: string, options?: NotifyOptions) => void; + confirm: (options: ConfirmOptions) => Promise; +} + +const LucentPromptContext = createContext(null); + +function ToneIcon({ tone }: { tone: PromptTone }) { + if (tone === 'success') return ; + if (tone === 'warning') return ; + if (tone === 'error') return ; + return ; +} + +export function LucentPromptProvider({ children }: { children: ReactNode }) { + const locale = useAppStore((s) => s.locale); + const [toasts, setToasts] = useState([]); + const [confirmState, setConfirmState] = useState(null); + const idRef = useRef(1); + + const notify = useCallback((message: string, options?: NotifyOptions) => { + const text = String(message || '').trim(); + if (!text) return; + const id = idRef.current++; + const tone = options?.tone || 'info'; + setToasts((prev) => [...prev, { id, title: options?.title, message: text, tone }]); + const ttl = Math.max(1400, Math.min(options?.durationMs || 2600, 8000)); + window.setTimeout(() => { + setToasts((prev) => prev.filter((item) => item.id !== id)); + }, ttl); + }, []); + + const confirm = useCallback( + (options: ConfirmOptions) => + new Promise((resolve) => { + const message = String(options?.message || '').trim(); + if (!message) { + resolve(false); + return; + } + setConfirmState({ + title: options?.title, + message, + tone: options?.tone || 'warning', + confirmText: options?.confirmText || (locale === 'zh' ? '确定' : 'Confirm'), + cancelText: options?.cancelText || (locale === 'zh' ? '取消' : 'Cancel'), + resolve, + }); + }), + [locale], + ); + + const closeConfirm = useCallback( + (value: boolean) => { + setConfirmState((prev) => { + if (prev) prev.resolve(value); + return null; + }); + }, + [], + ); + + const value = useMemo( + () => ({ + notify, + confirm, + }), + [notify, confirm], + ); + + return ( + + {children} + +
+ {toasts.map((toast) => ( +
+ + + +
+ {toast.title ?
{toast.title}
: null} +
{toast.message}
+
+
+ ))} +
+ + {confirmState ? ( +
closeConfirm(false)}> +
e.stopPropagation()}> +
+ + + +
+ {confirmState.title || (locale === 'zh' ? '请确认操作' : 'Please Confirm')} +
+ +
+
{confirmState.message}
+
+ + +
+
+
+ ) : null} +
+ ); +} + +export function useLucentPrompt() { + const ctx = useContext(LucentPromptContext); + if (!ctx) { + throw new Error('useLucentPrompt must be used inside LucentPromptProvider'); + } + return ctx; +} + diff --git a/frontend/src/components/lucent/lucent-prompt.css b/frontend/src/components/lucent/lucent-prompt.css new file mode 100644 index 0000000..a258556 --- /dev/null +++ b/frontend/src/components/lucent/lucent-prompt.css @@ -0,0 +1,146 @@ +.lucent-toast-stack { + position: fixed; + right: 16px; + top: 16px; + z-index: 1800; + display: grid; + gap: 10px; + width: min(360px, calc(100vw - 24px)); +} + +.lucent-toast { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: start; + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 12px; + background: color-mix(in oklab, var(--panel) 78%, transparent); + backdrop-filter: blur(12px); + box-shadow: 0 12px 28px rgba(8, 14, 30, 0.26); +} + +.lucent-toast.tone-success { + border-color: rgba(87, 204, 146, 0.44); +} + +.lucent-toast.tone-warning { + border-color: rgba(242, 186, 92, 0.44); +} + +.lucent-toast.tone-error { + border-color: rgba(223, 109, 109, 0.44); +} + +.lucent-toast-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon); +} + +.lucent-toast-body { + min-width: 0; +} + +.lucent-toast-title { + font-size: 12px; + font-weight: 800; + color: var(--title); + margin-bottom: 3px; +} + +.lucent-toast-message { + font-size: 13px; + color: var(--text); + line-height: 1.45; + word-break: break-word; +} + +.lucent-confirm-mask { + position: fixed; + inset: 0; + z-index: 1900; + background: rgba(8, 13, 24, 0.52); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.lucent-confirm-card { + width: min(520px, calc(100vw - 24px)); + border: 1px solid var(--line); + border-radius: 14px; + background: color-mix(in oklab, var(--panel) 86%, transparent); + box-shadow: 0 16px 40px rgba(8, 14, 30, 0.36); + padding: 14px; + display: grid; + gap: 12px; +} + +.lucent-confirm-card.tone-warning { + border-color: rgba(242, 186, 92, 0.44); +} + +.lucent-confirm-card.tone-error { + border-color: rgba(223, 109, 109, 0.46); +} + +.lucent-confirm-card.tone-success { + border-color: rgba(87, 204, 146, 0.46); +} + +.lucent-confirm-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; +} + +.lucent-confirm-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon); +} + +.lucent-confirm-title { + font-size: 15px; + font-weight: 800; + color: var(--title); +} + +.lucent-confirm-close { + border: 1px solid var(--line); + border-radius: 8px; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in oklab, var(--panel-soft) 80%, var(--panel) 20%); + color: var(--icon); + cursor: pointer; +} + +.lucent-confirm-message { + color: var(--text); + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; +} + +.lucent-confirm-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts new file mode 100644 index 0000000..77c5afc --- /dev/null +++ b/frontend/src/config/env.ts @@ -0,0 +1,32 @@ +function browserOrigin() { + if (typeof window === 'undefined') return 'http://localhost:8000'; + return window.location.origin; +} + +function browserWsOrigin() { + if (typeof window === 'undefined') return 'ws://localhost:8000'; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}`; +} + +function normalizeApiBase(raw?: string) { + const value = String(raw || '').trim(); + if (!value) return `${browserOrigin()}/api`; + if (value.startsWith('/')) return value; + return value; +} + +function normalizeWsBase(raw?: string) { + const value = String(raw || '').trim(); + if (!value) return `${browserWsOrigin()}/ws/monitor`; + if (value.startsWith('/')) return `${browserWsOrigin()}${value}`; + return value; +} + +const apiBase = normalizeApiBase(import.meta.env.VITE_API_BASE); +const wsBase = normalizeWsBase(import.meta.env.VITE_WS_BASE); + +export const APP_ENDPOINTS = { + apiBase, + wsBase, +}; diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts new file mode 100644 index 0000000..f5a967b --- /dev/null +++ b/frontend/src/hooks/useBotsSync.ts @@ -0,0 +1,242 @@ +import { useEffect, useRef } from 'react'; +import axios from 'axios'; +import { useAppStore } from '../store/appStore'; +import { APP_ENDPOINTS } from '../config/env'; +import type { BotState, ChatMessage } from '../types/bot'; +import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../modules/dashboard/messageParser'; +import { pickLocale } from '../i18n'; +import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn'; +import { botsSyncEn } from '../i18n/bots-sync.en'; + +function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { + const s = (v || '').toUpperCase(); + if (s === 'THINKING' || s === 'TOOL_CALL' || s === 'SUCCESS' || s === 'ERROR') return s; + return 'INFO'; +} + +function normalizeBusState(isTool: boolean): 'THINKING' | 'TOOL_CALL' { + return isTool ? 'TOOL_CALL' : 'THINKING'; +} + +function normalizeMedia(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.map((v) => String(v || '').trim()).filter((v) => v.length > 0); +} + +export function useBotsSync() { + const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); + const socketsRef = useRef>({}); + const heartbeatsRef = useRef>({}); + const lastUserEchoRef = useRef>({}); + const lastAssistantRef = useRef>({}); + const hydratedMessagesRef = useRef>({}); + const isZh = useAppStore((s) => s.locale === 'zh'); + const locale = useAppStore((s) => s.locale); + const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn }); + + useEffect(() => { + const fetchBots = async () => { + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); + setBots(res.data); + } catch (error) { + console.error('Failed to fetch bots', error); + } + }; + + fetchBots(); + const interval = window.setInterval(fetchBots, 5000); + + return () => { + window.clearInterval(interval); + }; + }, [setBots]); + + useEffect(() => { + const botIds = Object.keys(activeBots); + const aliveIds = new Set(botIds); + Object.keys(hydratedMessagesRef.current).forEach((botId) => { + if (!aliveIds.has(botId)) { + delete hydratedMessagesRef.current[botId]; + } + }); + + botIds.forEach((botId) => { + if (hydratedMessagesRef.current[botId]) return; + hydratedMessagesRef.current[botId] = true; + void (async () => { + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, { + params: { limit: 300 }, + }); + const rows = Array.isArray(res.data) ? res.data : []; + const messages: ChatMessage[] = rows + .map((row) => { + const roleRaw = String(row?.role || '').toLowerCase(); + const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; + return { + role, + text: String(row?.text || ''), + attachments: normalizeMedia(row?.media), + ts: Number(row?.ts || Date.now()), + }; + }) + .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) + .slice(-300); + setBotMessages(botId, messages); + + const lastUser = [...messages].reverse().find((m) => m.role === 'user'); + if (lastUser) lastUserEchoRef.current[botId] = { text: lastUser.text, ts: lastUser.ts }; + const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant'); + if (lastAssistant) lastAssistantRef.current[botId] = { text: lastAssistant.text, ts: lastAssistant.ts }; + } catch (error) { + console.error(`Failed to fetch bot messages for ${botId}`, error); + } + })(); + }); + }, [activeBots, setBotMessages]); + + useEffect(() => { + const runningIds = new Set( + Object.values(activeBots) + .filter((bot) => bot.docker_status === 'RUNNING') + .map((bot) => bot.id), + ); + + Object.keys(socketsRef.current).forEach((botId) => { + if (!runningIds.has(botId)) { + socketsRef.current[botId].close(); + delete socketsRef.current[botId]; + } + }); + + Object.values(activeBots).forEach((bot) => { + if (bot.docker_status !== 'RUNNING') { + return; + } + if (socketsRef.current[bot.id]) { + return; + } + + const ws = new WebSocket(`${APP_ENDPOINTS.wsBase}/${bot.id}`); + ws.onopen = () => { + const beat = window.setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('ping'); + } + }, 15000); + heartbeatsRef.current[bot.id] = beat; + }; + ws.onmessage = (event) => { + let data: any; + try { + data = JSON.parse(event.data); + } catch { + return; + } + const sourceChannel = String(data?.channel || '').trim().toLowerCase(); + const isDashboardChannel = sourceChannel === 'dashboard'; + const payload = data?.payload && typeof data.payload === 'object' ? data.payload : {}; + if (data.type === 'AGENT_STATE') { + const state = String(payload.state || data.state || 'INFO'); + const messageRaw = String(payload.action_msg || payload.msg || data.action_msg || data.msg || ''); + const message = summarizeProgressText(messageRaw, isZh); + updateBotState(bot.id, state, message); + addBotEvent(bot.id, { + state: normalizeState(state), + text: message || t.stateUpdated, + ts: Date.now(), + channel: sourceChannel || undefined, + }); + return; + } + if (data.type === 'ASSISTANT_MESSAGE') { + if (!isDashboardChannel) return; + const text = normalizeAssistantMessageText(String(data.text || payload.text || payload.content || '')); + const attachments = normalizeMedia(data.media || payload.media); + if (!text && attachments.length === 0) return; + const now = Date.now(); + const prev = lastAssistantRef.current[bot.id]; + if (prev && prev.text === text && now - prev.ts < 5000 && attachments.length === 0) return; + lastAssistantRef.current[bot.id] = { text, ts: now }; + addBotMessage(bot.id, { role: 'assistant', text, attachments, ts: now, kind: 'final' }); + updateBotState(bot.id, 'IDLE', ''); + addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined }); + return; + } + if (data.type === 'BUS_EVENT') { + const content = normalizeAssistantMessageText(String(data.content || payload.content || '')); + const isProgress = Boolean(data.is_progress); + const isTool = Boolean(data.is_tool); + if (isProgress) { + const state = normalizeBusState(isTool); + const progressText = summarizeProgressText(content, isZh); + updateBotState(bot.id, state, progressText); + addBotEvent(bot.id, { state, text: progressText || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); + if (isDashboardChannel && isTool) { + const chatText = + (isZh ? '调用工具:' : 'Tool: ') + (progressText || (isZh ? '处理中...' : 'processing...')); + addBotMessage(bot.id, { + role: 'assistant', + text: chatText, + ts: Date.now(), + kind: 'progress', + }); + } + return; + } + if (!isDashboardChannel) return; + if (content) { + const now = Date.now(); + const prev = lastAssistantRef.current[bot.id]; + if (!prev || prev.text !== content || now - prev.ts >= 5000) { + addBotMessage(bot.id, { role: 'assistant', text: content, ts: now, kind: 'final' }); + lastAssistantRef.current[bot.id] = { text: content, ts: now }; + } + updateBotState(bot.id, 'IDLE', summarizeProgressText(content, isZh)); + addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined }); + } + return; + } + if (data.type === 'USER_COMMAND') { + if (!isDashboardChannel) return; + const text = normalizeUserMessageText(String(data.text || payload.text || payload.command || '')); + const attachments = normalizeMedia(data.media || payload.media); + if (!text && attachments.length === 0) return; + const now = Date.now(); + const prev = lastUserEchoRef.current[bot.id]; + if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return; + lastUserEchoRef.current[bot.id] = { text, ts: now }; + addBotMessage(bot.id, { role: 'user', text, attachments, ts: now, kind: 'final' }); + return; + } + if (data.type === 'RAW_LOG') { + addBotLog(bot.id, String(data.text || '')); + } + }; + ws.onclose = () => { + const hb = heartbeatsRef.current[bot.id]; + if (hb) { + window.clearInterval(hb); + delete heartbeatsRef.current[bot.id]; + } + delete socketsRef.current[bot.id]; + }; + + socketsRef.current[bot.id] = ws; + }); + + return () => { + // no-op: clean in unmount effect below + }; + }, [activeBots, addBotEvent, addBotLog, addBotMessage, isZh, t.progress, t.replied, t.stateUpdated, updateBotState]); + + useEffect(() => { + return () => { + Object.values(socketsRef.current).forEach((ws) => ws.close()); + Object.values(heartbeatsRef.current).forEach((timerId) => window.clearInterval(timerId)); + heartbeatsRef.current = {}; + socketsRef.current = {}; + }; + }, []); +} diff --git a/frontend/src/i18n/README.md b/frontend/src/i18n/README.md new file mode 100644 index 0000000..fba78d7 --- /dev/null +++ b/frontend/src/i18n/README.md @@ -0,0 +1,23 @@ +# I18N Convention + +Use this convention for every new page/module: + +1. Add two language packs in this folder: +- `.zh-cn.ts` +- `.en.ts` + +2. Export plain objects/functions from both files with the same key shape. + +3. In component code, select pack via: + +```ts +import { pickLocale } from '../i18n'; +import { xxxZhCn } from '../i18n/xxx.zh-cn'; +import { xxxEn } from '../i18n/xxx.en'; + +const t = pickLocale(locale, { 'zh-cn': xxxZhCn, en: xxxEn }); +``` + +4. Do not add inline `isZh ? ... : ...` strings in page code. + +5. Keep dynamic text in translation pack as small helper functions. diff --git a/frontend/src/i18n/app.en.ts b/frontend/src/i18n/app.en.ts new file mode 100644 index 0000000..a8361ec --- /dev/null +++ b/frontend/src/i18n/app.en.ts @@ -0,0 +1,15 @@ +export const appEn = { + title: 'Nanobot Control Plane', + theme: 'Theme', + language: 'Language', + dark: 'Dark', + light: 'Light', + zh: 'Chinese', + en: 'English', + close: 'Close', + nav: { + images: { title: 'Image Factory', subtitle: 'Manage registered images' }, + onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' }, + dashboard: { title: 'Ops Dashboard', subtitle: 'Chat, status, logs, controls' }, + }, +}; diff --git a/frontend/src/i18n/app.zh-cn.ts b/frontend/src/i18n/app.zh-cn.ts new file mode 100644 index 0000000..c3cc93e --- /dev/null +++ b/frontend/src/i18n/app.zh-cn.ts @@ -0,0 +1,15 @@ +export const appZhCn = { + title: 'Nanobot 管理面板', + theme: '主题', + language: '语言', + dark: '深色', + light: '浅色', + zh: '中文', + en: 'English', + close: '关闭', + nav: { + images: { title: '镜像工厂', subtitle: '管理已登记镜像' }, + onboarding: { title: '创建向导', subtitle: '分步创建 Bot' }, + dashboard: { title: '运维 Dashboard', subtitle: '对话、状态、日志与启停' }, + }, +}; diff --git a/frontend/src/i18n/bots-sync.en.ts b/frontend/src/i18n/bots-sync.en.ts new file mode 100644 index 0000000..417d857 --- /dev/null +++ b/frontend/src/i18n/bots-sync.en.ts @@ -0,0 +1,5 @@ +export const botsSyncEn = { + stateUpdated: 'state updated', + replied: 'Replied', + progress: 'progress', +}; diff --git a/frontend/src/i18n/bots-sync.zh-cn.ts b/frontend/src/i18n/bots-sync.zh-cn.ts new file mode 100644 index 0000000..1a81e77 --- /dev/null +++ b/frontend/src/i18n/bots-sync.zh-cn.ts @@ -0,0 +1,5 @@ +export const botsSyncZhCn = { + stateUpdated: '状态已更新', + replied: '已回复', + progress: '处理中', +}; diff --git a/frontend/src/i18n/channels.en.ts b/frontend/src/i18n/channels.en.ts new file mode 100644 index 0000000..eecb96b --- /dev/null +++ b/frontend/src/i18n/channels.en.ts @@ -0,0 +1,33 @@ +export const channelsEn = { + wizardSectionTitle: 'Channel Configuration', + wizardSectionDesc: 'Manage custom channels.', + globalDeliveryTitle: 'Global Delivery', + globalDeliveryDesc: '`sendProgress` / `sendToolHints` are global switches and apply to all channels.', + openManager: 'Manage Channels', + defaultChannel: 'Default Channel', + customChannel: 'Custom Channel', + dashboardLocked: 'Dashboard is required and cannot be removed.', + enabled: 'Enabled', + saveChannel: 'Save', + addChannel: 'Add', + close: 'Close', + remove: 'Remove', + sendProgress: 'sendProgress', + sendToolHints: 'sendToolHints', + fieldPrimary: 'Primary Field', + fieldSecret: 'Secret Field', + fieldPort: 'Internal Port', + appId: 'App ID', + appSecret: 'App Secret', + clientId: 'Client ID', + clientSecret: 'Client Secret', + botToken: 'Bot Token', + appToken: 'App Token', + telegramToken: 'Telegram Bot Token', + proxy: 'Proxy (optional)', + replyToMessage: 'Reply to original message', + encryptKey: 'Encrypt Key (optional)', + verificationToken: 'Verification Token (optional)', + autoRefresh: 'Auto', + refreshHint: 'Refresh directory', +} as const; diff --git a/frontend/src/i18n/channels.zh-cn.ts b/frontend/src/i18n/channels.zh-cn.ts new file mode 100644 index 0000000..ecf654f --- /dev/null +++ b/frontend/src/i18n/channels.zh-cn.ts @@ -0,0 +1,33 @@ +export const channelsZhCn = { + wizardSectionTitle: '渠道配置', + wizardSectionDesc: '管理自定义渠道。', + globalDeliveryTitle: '全局消息投递', + globalDeliveryDesc: 'sendProgress / sendToolHints 为全局开关,对所有渠道统一生效。', + openManager: '管理渠道', + defaultChannel: '默认渠道', + customChannel: '自定义渠道', + dashboardLocked: 'Dashboard 为系统必选渠道,不能删除。', + enabled: '启用', + saveChannel: '保存', + addChannel: '新增', + close: '关闭', + remove: '删除', + sendProgress: 'sendProgress', + sendToolHints: 'sendToolHints', + fieldPrimary: '主字段', + fieldSecret: '密钥字段', + fieldPort: '内部端口', + appId: 'App ID', + appSecret: 'App Secret', + clientId: 'Client ID', + clientSecret: 'Client Secret', + botToken: 'Bot Token', + appToken: 'App Token', + telegramToken: 'Telegram Bot Token', + proxy: '代理(可选)', + replyToMessage: '回复时引用原消息', + encryptKey: 'Encrypt Key(可选)', + verificationToken: 'Verification Token(可选)', + autoRefresh: '自动', + refreshHint: '手动刷新目录', +} as const; diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts new file mode 100644 index 0000000..0c84fc5 --- /dev/null +++ b/frontend/src/i18n/dashboard.en.ts @@ -0,0 +1,136 @@ +export const dashboardEn = { + processing: 'Processing...', + executingWith: (action: string) => `Executing: ${action}`, + executing: 'Nanobot is executing tools...', + thinkingWith: (action: string) => `Thinking: ${action}`, + thinking: 'Nanobot is thinking...', + fileReadFail: 'Failed to read file.', + workspaceLoadFail: 'Failed to load workspace tree.', + channelSaved: 'Channel saved.', + channelSaveFail: 'Failed to save channel.', + channelAddFail: 'Failed to add channel.', + channelDeleteConfirm: (channelType: string) => `Delete channel ${channelType}?`, + channelDeleteFail: 'Failed to delete channel.', + stopFail: 'Stop failed. Check backend logs.', + startFail: 'Start failed. Check backend logs.', + backendDeliverFail: 'Backend failed to deliver command.', + sendFail: 'Send failed.', + uploadFile: 'Upload file', + uploadingFile: 'Uploading...', + uploadFail: 'File upload failed.', + 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.', + saveFail: 'Save failed.', + deleteBotConfirm: (id: string) => `Delete bot ${id}? Workspace will be removed.`, + deleteBotDone: 'Bot and workspace deleted.', + deleteFail: 'Delete failed.', + titleBots: 'Bots', + newBot: 'New Bot', + manageImages: 'Image Manager', + image: 'Image', + stop: 'Stop', + start: 'Start', + delete: 'Delete', + noConversation: 'No conversation yet. Send a command and bot replies will appear here.', + clearHistory: 'Clear history', + clearHistoryConfirm: (name: string) => `Clear dashboard conversation history for ${name}?`, + clearHistoryDone: 'Conversation history cleared.', + clearHistoryFail: 'Failed to clear conversation history.', + exportHistory: 'Export JSON', + exportHistoryFail: 'Failed to export conversation.', + you: 'You', + user: 'User', + inputPlaceholder: 'Type a command and press Enter (Shift+Enter for newline)', + disabledPlaceholder: 'Bot is not running. Command input is disabled.', + sending: 'Sending...', + send: 'Send', + botStarting: 'Bot is starting...', + botStopping: 'Bot is stopping...', + chatDisabled: 'Bot is stopped. Chat area is disabled.', + selectBot: 'Select a bot to inspect', + runtime: 'Runtime Status', + base: 'Base', + params: 'Params', + channels: 'Channels', + skills: 'Skills', + tools: 'Tools', + skillsPanel: 'Skills Panel', + skillsEmpty: 'No skills.', + addSkill: 'Add skill', + removeSkill: 'Remove skill', + uploadZipSkill: 'Upload ZIP Skill Pack', + zipOnlyHint: 'Only .zip files are supported', + invalidZipFile: 'Only .zip files are allowed', + envParams: 'Env Params', + envParamsDesc: 'Configure runtime env vars for this bot container.', + noEnvParams: 'No env vars configured.', + envKey: 'Key (e.g. BRAVE_API_KEY)', + envValue: 'Value', + showEnvValue: 'Show value', + hideEnvValue: 'Hide value', + addEnvParam: 'Add env var', + removeEnvParam: 'Remove env var', + envParamsSaved: 'Env params saved.', + envParamsSaveFail: 'Failed to save env params.', + envParamsHint: 'Restart bot to apply updated env vars.', + toolsLoadFail: 'Failed to load tool skills.', + toolsAddFail: 'Failed to add tool.', + toolsRemoveFail: 'Failed to remove tool.', + toolsRemoveConfirm: (name: string) => `Remove skill ${name}?`, + agent: 'Agent', + container: 'Container', + current: 'Current', + lastAction: 'Last action', + workspaceOutputs: 'Workspace', + autoRefresh: 'Auto refresh', + loadingDir: 'Loading directory...', + emptyDir: 'Current directory is empty.', + openingPreview: 'Opening file preview...', + workspaceHint: 'Click folder to enter; click .md/.json to preview.', + noPreviewFile: 'No previewable files in this directory.', + noTelemetry: 'No telemetry selected', + goUpTitle: 'Click to go up', + goUp: 'Go up', + openFolderTitle: 'Click to open folder', + folder: 'Folder', + previewTitle: 'Click to preview', + fileNotPreviewable: 'File type not previewable', + baseConfig: 'Base Configuration', + baseConfigSub: 'Only editable: name and LLM access settings', + botIdReadonly: 'Bot ID (Read-only)', + botName: 'Bot Name', + botNamePlaceholder: 'Bot name', + baseImageReadonly: 'Base Image (Read-only)', + modelName: 'Model Name', + modelNamePlaceholder: 'e.g. qwen-plus', + newApiKey: 'New API Key (optional)', + newApiKeyPlaceholder: 'Only updated when filled', + testing: 'Testing...', + testModelConnection: 'Test model connection', + cancel: 'Cancel', + save: 'Save', + modelParams: 'Model Parameters', + saveParams: 'Save Params', + agentFiles: 'Agent Files', + saveFiles: 'Save Files', + filePreview: 'File Preview', + fileTruncated: 'Large file: preview is truncated.', + download: 'Download', + close: 'Close', + cronViewer: 'Scheduled Jobs', + cronReload: 'Reload jobs', + cronLoading: 'Loading jobs...', + cronEmpty: 'No scheduled jobs.', + cronEnabled: 'Enabled', + cronDisabled: 'Disabled', + cronStop: 'Stop job', + cronDelete: 'Delete job', + cronStopFail: 'Failed to stop job.', + cronDeleteFail: 'Failed to delete job.', + cronDeleteConfirm: (id: string) => `Delete scheduled job ${id}?`, +}; diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts new file mode 100644 index 0000000..9c27231 --- /dev/null +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -0,0 +1,136 @@ +export const dashboardZhCn = { + processing: '处理中...', + executingWith: (action: string) => `执行中:${action}`, + executing: 'Nanobot 正在执行工具...', + thinkingWith: (action: string) => `思考中:${action}`, + thinking: 'Nanobot 正在思考中...', + fileReadFail: '文件读取失败。', + workspaceLoadFail: '无法读取 workspace 目录。', + channelSaved: '渠道配置已保存。', + channelSaveFail: '渠道保存失败。', + channelAddFail: '新增渠道失败。', + channelDeleteConfirm: (channelType: string) => `确认删除渠道 ${channelType}?`, + channelDeleteFail: '删除渠道失败。', + stopFail: '停止失败,请查看后端日志。', + startFail: '启动失败,请查看后端日志。', + backendDeliverFail: '后端未成功投递指令。', + sendFail: '发送失败。', + uploadFile: '上传文件', + uploadingFile: '上传中...', + uploadFail: '文件上传失败。', + attachmentMessage: '[附件消息]', + removeAttachment: '移除附件', + sendFailMsg: (msg: string) => `指令发送失败:${msg}`, + providerRequired: '请填写 Provider、模型和新 API Key 后再测试。', + connOk: (preview: string) => (preview ? `连接成功,模型: ${preview}` : '连接成功'), + connFail: (msg: string) => `连接失败: ${msg}`, + configUpdated: '配置已更新。', + saveFail: '保存失败。', + deleteBotConfirm: (id: string) => `确认删除 Bot ${id}?将删除对应 workspace。`, + deleteBotDone: 'Bot 与 workspace 已删除。', + deleteFail: '删除失败。', + titleBots: 'Bot 列表', + newBot: '新建机器人', + manageImages: '镜像管理', + image: '镜像', + stop: '停止', + start: '启动', + delete: '删除', + noConversation: '暂无对话消息。请先发送指令,Bot 回复会在这里按标准会话格式展示。', + clearHistory: '清空对话', + clearHistoryConfirm: (name: string) => `确认清空 ${name} 的面板对话历史?`, + clearHistoryDone: '对话历史已清空。', + clearHistoryFail: '清空对话历史失败。', + exportHistory: '导出对话', + exportHistoryFail: '导出对话失败。', + you: '你', + user: '用户', + inputPlaceholder: '输入指令后回车发送(Shift+Enter 换行)', + disabledPlaceholder: 'Bot 未运行,无法发送指令。', + sending: '发送中...', + send: '发送', + botStarting: 'Bot 正在启动中...', + botStopping: 'Bot 正在停止中...', + chatDisabled: 'Bot 已停止,对话区已禁用。', + selectBot: '请选择 Bot 查看详情', + runtime: '运行状态', + base: '基础', + params: '参数', + channels: '渠道', + skills: '技能', + tools: '工具', + skillsPanel: '技能面板', + skillsEmpty: '暂无技能。', + addSkill: '新增技能', + removeSkill: '移除技能', + uploadZipSkill: '上传 ZIP 技能包', + zipOnlyHint: '仅支持 .zip 文件', + invalidZipFile: '只能上传 .zip 文件', + envParams: '环境变量', + envParamsDesc: '配置该 Bot 的运行时环境变量(容器级)。', + noEnvParams: '暂无环境变量。', + envKey: '变量名(如 BRAVE_API_KEY)', + envValue: '变量值', + showEnvValue: '显示变量值', + hideEnvValue: '隐藏变量值', + addEnvParam: '新增环境变量', + removeEnvParam: '移除环境变量', + envParamsSaved: '环境变量已保存。', + envParamsSaveFail: '环境变量保存失败。', + envParamsHint: '修改后需重启 Bot 才会生效。', + toolsLoadFail: '读取工具技能失败。', + toolsAddFail: '新增工具失败。', + toolsRemoveFail: '移除工具失败。', + toolsRemoveConfirm: (name: string) => `确认移除技能 ${name}?`, + agent: '代理', + container: '容器状态', + current: '当前状态', + lastAction: '最后动作', + workspaceOutputs: '工作区', + autoRefresh: '自动刷新', + loadingDir: '读取目录中...', + emptyDir: '当前目录为空。', + openingPreview: '正在打开文件预览...', + workspaceHint: '点击目录进入;点击 .md/.json 文件弹出预览。', + noPreviewFile: '本目录没有可预览文件。', + noTelemetry: '暂无可展示信息', + goUpTitle: '点击返回上级目录', + goUp: '返回上级', + openFolderTitle: '点击进入目录', + folder: '目录', + previewTitle: '点击打开预览', + fileNotPreviewable: '该文件类型不可预览', + baseConfig: '基础配置', + baseConfigSub: '仅允许修改名称和大模型接入信息', + botIdReadonly: 'Bot ID(只读)', + botName: 'Bot 名称', + botNamePlaceholder: '输入 Bot 名称', + baseImageReadonly: '基础镜像(只读)', + modelName: '模型名称', + modelNamePlaceholder: '如 qwen-plus', + newApiKey: '新的 API Key(留空不更新)', + newApiKeyPlaceholder: '输入新 Key 才会更新', + testing: '测试中...', + testModelConnection: '测试模型连接', + cancel: '取消', + save: '保存', + modelParams: '模型参数设置', + saveParams: '保存参数', + agentFiles: '代理文件配置', + saveFiles: '保存文件', + filePreview: '文件预览', + fileTruncated: '文件较大,当前内容为截断预览。', + download: '下载', + close: '关闭', + cronViewer: '定时任务', + cronReload: '刷新任务', + cronLoading: '读取任务中...', + cronEmpty: '暂无定时任务。', + cronEnabled: '启用', + cronDisabled: '已停用', + cronStop: '停止任务', + cronDelete: '删除任务', + cronStopFail: '停止任务失败。', + cronDeleteFail: '删除任务失败。', + cronDeleteConfirm: (id: string) => `确认删除任务 ${id}?`, +}; diff --git a/frontend/src/i18n/image-factory.en.ts b/frontend/src/i18n/image-factory.en.ts new file mode 100644 index 0000000..df8c0cb --- /dev/null +++ b/frontend/src/i18n/image-factory.en.ts @@ -0,0 +1,23 @@ +export const imageFactoryEn = { + fetchFail: 'Failed to fetch images. Check backend.', + registerFail: 'Image registration failed', + deleteFail: 'Delete failed', + deleteConfirm: (tag: string) => `Delete image registration?\n${tag}\n\nThis only removes DB registration, not local Docker image.`, + registeredTitle: 'Registered Base Images', + registeredDesc: 'Wizard can only use READY images from DB registry.', + refreshing: 'Refreshing...', + refresh: 'Refresh', + partialLoadFail: (detail: string) => `Partial load failure: ${detail}`, + registeredCount: 'Registered', + abnormal: 'Abnormal', + action: 'Action', + deleting: 'Deleting...', + deleteRegistry: 'Delete', + noRegistered: 'No registered image in DB.', + dockerTitle: 'Docker Local Images', + dockerDesc: 'System no longer scans engines. Register from docker images only.', + dockerTip: 'Build manually then register: docker build -f Dashboard.Dockerfile.manual -t nanobot-base:v0.1.4 .', + update: 'Update', + register: 'Register', + noDocker: 'No local nanobot-base:* image found.', +}; diff --git a/frontend/src/i18n/image-factory.zh-cn.ts b/frontend/src/i18n/image-factory.zh-cn.ts new file mode 100644 index 0000000..257a9da --- /dev/null +++ b/frontend/src/i18n/image-factory.zh-cn.ts @@ -0,0 +1,23 @@ +export const imageFactoryZhCn = { + fetchFail: '获取镜像列表失败,请检查后端。', + registerFail: '镜像注册失败', + deleteFail: '删除失败', + deleteConfirm: (tag: string) => `确认删除该镜像登记?\n${tag}\n\n仅删除数据库登记,不会删除 Docker 本地镜像。`, + registeredTitle: '已有基础镜像(数据库登记)', + registeredDesc: '创建向导只允许使用 READY 的已登记镜像。', + refreshing: '刷新中...', + refresh: '刷新', + partialLoadFail: (detail: string) => `部分数据加载失败: ${detail}`, + registeredCount: '登记总数', + abnormal: '异常状态', + action: '操作', + deleting: '删除中...', + deleteRegistry: '删除登记', + noRegistered: '数据库暂无登记镜像。', + dockerTitle: '可登记镜像(Docker 本地)', + dockerDesc: '系统不再扫描 engines,仅从 docker images 获取并手工登记。', + dockerTip: '建议手工构建后再登记: docker build -f Dashboard.Dockerfile.manual -t nanobot-base:v0.1.4 .', + update: '更新登记', + register: '加入数据库', + noDocker: '本地没有 nanobot-base:* 镜像,请先手工构建。', +}; diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..e5027b4 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,11 @@ +import type { AppLocale } from '../store/appStore'; + +export type UiLocale = 'zh-cn' | 'en'; + +export function toUiLocale(locale: AppLocale): UiLocale { + return locale === 'zh' ? 'zh-cn' : 'en'; +} + +export function pickLocale(locale: AppLocale, packs: Record): T { + return packs[toUiLocale(locale)]; +} diff --git a/frontend/src/i18n/legacy-deck.en.ts b/frontend/src/i18n/legacy-deck.en.ts new file mode 100644 index 0000000..2927248 --- /dev/null +++ b/frontend/src/i18n/legacy-deck.en.ts @@ -0,0 +1,11 @@ +export const legacyDeckEn = { + operationsDeck: 'OPERATIONS DECK', + sandbox: 'Neural Network Sandbox', + activeNodes: 'ACTIVE NODES', + globalEvents: 'Global Events', + listening: 'listening for commands', + standby: 'All units in standby mode. Waiting for deployment...', + deckTitle: 'Digital Employee Deck', + deckSub: 'Realtime bot execution and data flow visibility', + idle: 'IDLE', +}; diff --git a/frontend/src/i18n/legacy-deck.zh-cn.ts b/frontend/src/i18n/legacy-deck.zh-cn.ts new file mode 100644 index 0000000..ed6c180 --- /dev/null +++ b/frontend/src/i18n/legacy-deck.zh-cn.ts @@ -0,0 +1,11 @@ +export const legacyDeckZhCn = { + operationsDeck: '运维控制台', + sandbox: '神经网络沙盒', + activeNodes: '活跃节点', + globalEvents: '全局事件', + listening: '等待指令', + standby: '所有单元待命中,等待部署...', + deckTitle: '数字员工工位', + deckSub: '实时观察机器人执行状态与数据流转', + idle: 'IDLE', +}; diff --git a/frontend/src/i18n/management.en.ts b/frontend/src/i18n/management.en.ts new file mode 100644 index 0000000..bd1a125 --- /dev/null +++ b/frontend/src/i18n/management.en.ts @@ -0,0 +1,54 @@ +export const managementEn = { + opFail: 'Operation failed. Check Docker.', + botInstances: 'Bot Instances', + newBot: 'New Bot', + detailsTitle: 'Instance configuration and runtime details', + kernel: 'Kernel', + model: 'Model', + status: 'Status', + consoleOutput: 'Console Output', + selectHint: 'Select an instance to view detailed diagnostics.', + idle: 'IDLE', + standingBy: 'Standing by...', + create: { + createFail: 'Create failed. Check backend service and duplicate ID.', + title: 'Create New Digital Worker', + idLabel: 'Unique ID', + idPlaceholder: 'e.g. research_bot_01', + nameLabel: 'Worker Name', + namePlaceholder: 'e.g. Market Research Expert', + imageLabel: 'Kernel Image (Docker Image)', + noImage: 'No available kernel image found', + imageHint: 'Only READY images can be used to create a bot', + providerLabel: 'Model Provider', + modelLabel: 'Model', + modelPlaceholder: 'gpt-4o / deepseek-chat', + soulLabel: 'Persona Prompt (SOUL.md)', + cancel: 'Cancel', + submit: 'Create Bot', + source: 'Source', + pypi: 'PyPI', + systemPrompt: 'You are a professional digital worker assistant.', + }, + kernelModal: { + fetchFailed: 'Fetch failed', + removeConfirm: (tag: string) => `Remove image registration?\n${tag}`, + removeFail: 'Remove failed', + needVersionAndEngine: 'Please enter version and select source directory', + buildDispatched: 'Build task submitted. Watch status updates on the right list.', + buildDispatchFail: 'Failed to submit build task', + title: 'Nanobot Kernel Manager (Image Factory)', + buildSection: 'Build New Kernel Image', + selectEngine: '1. Select local source directory (engines/)', + noEngine: 'No source detected (please git clone into engines/)', + buildHint: 'Backend scans this directory and runs `pip install .` for image build', + defineVersion: '2. Define version tag', + versionPlaceholder: 'e.g. 0.1.4-custom', + startBuild: 'Start Build', + readySection: 'Ready Image Versions', + source: 'Source', + pypi: 'PyPI', + removeRecord: 'Remove record', + footerHint: 'Note: image build runs asynchronously in host Docker. Source must include `pyproject.toml` or `setup.py`.', + }, +}; diff --git a/frontend/src/i18n/management.zh-cn.ts b/frontend/src/i18n/management.zh-cn.ts new file mode 100644 index 0000000..f7b7be3 --- /dev/null +++ b/frontend/src/i18n/management.zh-cn.ts @@ -0,0 +1,54 @@ +export const managementZhCn = { + opFail: '操作失败,请检查 Docker', + botInstances: 'Bot 实例', + newBot: '新建 Bot', + detailsTitle: '实例配置与运行时信息', + kernel: '内核', + model: '模型', + status: '状态', + consoleOutput: '控制台输出', + selectHint: '请选择实例查看详细诊断。', + idle: 'IDLE', + standingBy: '待命中...', + create: { + createFail: '创建失败,请检查后端服务是否正常及 ID 是否重复', + title: '孵化新的数字员工', + idLabel: '唯一标识 (ID)', + idPlaceholder: '例如: research_bot_01', + nameLabel: '员工姓名', + namePlaceholder: '例如: 市场调研专家', + imageLabel: '选择内核版本 (Docker Image)', + noImage: '未发现可用内核镜像', + imageHint: '只有状态为 READY 的镜像才能用于孵化机器人', + providerLabel: '模型供应商', + modelLabel: '模型型号', + modelPlaceholder: 'gpt-4o / deepseek-chat', + soulLabel: '人设提示词 (SOUL.md)', + cancel: '取消', + submit: '确认孵化', + source: '来源', + pypi: 'PyPI', + systemPrompt: '你是一个专业的数字员工助手。', + }, + kernelModal: { + fetchFailed: '加载失败', + removeConfirm: (tag: string) => `确定要移除镜像登记吗?\n${tag}`, + removeFail: '移除失败', + needVersionAndEngine: '请填写版本号并选择源码目录', + buildDispatched: '构建指令已下发,请在右侧列表关注状态更新', + buildDispatchFail: '构建指令发送失败', + title: 'Nanobot 内核管理 (Image Factory)', + buildSection: '构建新内核镜像', + selectEngine: '1. 选择本地源码目录 (engines/)', + noEngine: '未检测到源码 (请先 git clone 到 engines/)', + buildHint: '后端将扫描该目录并执行 `pip install .` 构建镜像', + defineVersion: '2. 定义版本号 (Version Tag)', + versionPlaceholder: '例如: 0.1.4-custom', + startBuild: '开始构建镜像', + readySection: '已就绪的镜像版本', + source: '来源', + pypi: 'PyPI', + removeRecord: '移除记录', + footerHint: '提示:镜像构建在宿主机 Docker 中异步执行。源码目录需包含 `pyproject.toml` 或 `setup.py`。', + }, +}; diff --git a/frontend/src/i18n/wizard.en.ts b/frontend/src/i18n/wizard.en.ts new file mode 100644 index 0000000..def1ef2 --- /dev/null +++ b/frontend/src/i18n/wizard.en.ts @@ -0,0 +1,58 @@ +export const wizardEn = { + title: 'Guided Bot Creation', + sub: 'Select image first, then configure model and agent files.', + s1: '1. Select READY image', + s2: '2. Model and params', + s3: '3. Agent files', + s4: '4. Confirm', + test: 'Test model connection', + testing: 'Testing...', + next: 'Next', + prev: 'Back', + finish: 'Create bot', + noReadyImage: 'No READY images. Register image first.', + requiredBase: 'Please complete required fields.', + providerRequired: 'Please set provider/model/API key first.', + connFailed: (msg: string) => `Failed: ${msg}`, + connOk: (preview: string) => (preview ? `Connection passed, models: ${preview}` : 'Connection passed'), + created: 'Bot created.', + createFailed: 'Create failed, check backend logs', + loading: 'Loading...', + loadImages: 'Load images', + noReady: 'No READY image.', + baseInfo: 'Base Info', + botIdPlaceholder: 'Bot ID', + botName: 'Bot Name', + modelAccess: 'Model Access', + modelNamePlaceholder: 'Model name', + modelParams: 'Model Parameters', + skills: 'Skills', + skillsConfig: 'Skills Configuration', + skillsDesc: 'Select skills to install for this bot.', + openSkillsManager: 'Open skills panel', + noSkillsSelected: 'No skills selected', + skillsSectionTitle: 'Skills Manager', + skillsEmpty: 'No skills available.', + addSkill: 'Add skill', + removeSkill: 'Remove skill', + tools: 'Env Params', + toolsConfig: 'Env Params', + toolsDesc: 'Configure runtime env vars (effective after bot restart).', + openToolsManager: 'Open env params panel', + toolsSectionTitle: 'Environment Variables', + envParamsDesc: 'Env vars are injected into this bot container for tools/skills.', + noEnvParams: 'No env vars configured.', + envKey: 'Key (e.g. BRAVE_API_KEY)', + envValue: 'Value', + showEnvValue: 'Show value', + hideEnvValue: 'Hide value', + addEnvParam: 'Add env var', + removeEnvParam: 'Remove env var', + tokenRange: 'Range: 256 - 32768', + image: 'Image', + name: 'Name', + model: 'Model', + channels: 'Channels', + autoStart: 'Auto start after creation', + creating: 'Creating...', +}; diff --git a/frontend/src/i18n/wizard.zh-cn.ts b/frontend/src/i18n/wizard.zh-cn.ts new file mode 100644 index 0000000..d81d307 --- /dev/null +++ b/frontend/src/i18n/wizard.zh-cn.ts @@ -0,0 +1,58 @@ +export const wizardZhCn = { + title: '引导式 Bot 创建', + sub: '先选镜像,再配置模型与代理文件。', + s1: '1. 选择 READY 镜像', + s2: '2. 模型与参数', + s3: '3. 代理文件配置', + s4: '4. 确认创建', + test: '测试模型连接', + testing: '测试中...', + next: '下一步', + prev: '上一步', + finish: '完成创建', + noReadyImage: '没有 READY 镜像,请先在镜像工厂登记。', + requiredBase: '请完整填写基础信息。', + providerRequired: '请先填写 provider、模型和 API Key。', + connFailed: (msg: string) => `连接失败: ${msg}`, + connOk: (preview: string) => (preview ? `连接成功,返回模型: ${preview}` : '连接成功'), + created: 'Bot 创建成功。', + createFailed: '创建失败,请检查后端日志', + loading: '加载中...', + loadImages: '加载镜像列表', + noReady: '暂无 READY 镜像。', + baseInfo: '基础信息', + botIdPlaceholder: 'Bot ID(如 analyst_bot_01)', + botName: 'Bot 名称', + modelAccess: '模型接入', + modelNamePlaceholder: '模型名(如 qwen-plus)', + modelParams: '模型参数', + skills: '技能', + skillsConfig: '技能配置', + skillsDesc: '选择该 Bot 需要安装的技能包。', + openSkillsManager: '打开技能面板', + noSkillsSelected: '未选择技能', + skillsSectionTitle: '技能管理', + skillsEmpty: '暂无可用技能。', + addSkill: '新增技能', + removeSkill: '移除技能', + tools: '环境变量', + toolsConfig: '环境变量配置', + toolsDesc: '配置运行时环境变量(容器重启后生效)。', + openToolsManager: '打开环境变量面板', + toolsSectionTitle: '环境变量', + envParamsDesc: '环境变量将注入该 Bot 容器,供 tools/skills 统一读取。', + noEnvParams: '暂无环境变量。', + envKey: '变量名(如 BRAVE_API_KEY)', + envValue: '变量值', + showEnvValue: '显示变量值', + hideEnvValue: '隐藏变量值', + addEnvParam: '新增环境变量', + removeEnvParam: '移除环境变量', + tokenRange: '范围: 256 - 32768', + image: '镜像', + name: '名称', + model: '模型', + channels: '渠道', + autoStart: '创建后自动启动', + creating: '创建中...', +}; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..d3147e6 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,7 @@ +html, +body, +#root { + margin: 0; + width: 100%; + min-height: 100vh; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..04229a4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css new file mode 100644 index 0000000..d348b6a --- /dev/null +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -0,0 +1,1573 @@ +.ops-bot-list { + min-width: 0; +} + +.ops-compact-hidden { + display: none !important; +} + +.ops-compact-fab-switch { + position: fixed; + right: 14px; + bottom: 14px; + width: 42px; + height: 42px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--brand) 58%, var(--line) 42%); + background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%); + color: var(--icon); + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 24px rgba(9, 15, 28, 0.35); + z-index: 85; + cursor: pointer; +} + +.ops-list-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ops-bot-card { + position: relative; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); + padding: 10px 10px 10px 14px; + margin-bottom: 10px; + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.ops-bot-card:hover { + border-color: var(--brand); +} + +.ops-bot-card.is-active { + border-color: var(--brand); + box-shadow: inset 0 0 0 1px var(--brand); +} + +.ops-bot-top { + align-items: flex-start; +} + +.ops-bot-name { + font-size: 16px; + font-weight: 800; + color: var(--title); +} + +.ops-bot-id, +.ops-bot-meta { + margin-top: 2px; + color: var(--subtitle); + font-size: 12px; + font-weight: 600; +} + +.ops-bot-actions { + margin-top: 10px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.ops-bot-strip { + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 4px; + border-radius: 999px; + background: #d14b4b; +} + +.ops-bot-strip.is-running { + background: #2ca476; +} + +.ops-bot-strip.is-stopped { + background: #d14b4b; +} + +.ops-bot-icon-btn { + width: 34px; + height: 34px; + padding: 0; + border-radius: 9px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ops-control-pending { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ops-control-dots { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.ops-control-dots i { + width: 4px; + height: 4px; + border-radius: 999px; + display: block; + background: currentColor; + opacity: 0.35; + animation: ops-control-dot 1.2s infinite ease-in-out; +} + +.ops-control-dots i:nth-child(2) { + animation-delay: 0.2s; +} + +.ops-control-dots i:nth-child(3) { + animation-delay: 0.4s; +} + +.btn-sm { + padding: 8px 10px; + font-size: 12px; +} + +.ops-chat-panel { + min-width: 0; + padding: 12px; +} + +.ops-chat-shell { + display: grid; + grid-template-rows: 1fr; + gap: 0; + height: 100%; + min-height: 0; +} + +.ops-chat-head { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.ops-chat-title { + margin: 0; + font-size: 18px; + font-weight: 800; + color: var(--title); +} + +.ops-chat-subtitle { + margin: 4px 0 0; + color: var(--subtitle); + font-size: 12px; + font-weight: 600; +} + +.ops-status-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.ops-status-pill { + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 800; + color: var(--text); + background: var(--panel); +} + +.ops-status-pill.running { + border-color: #2ca476; + background: rgba(44, 164, 118, 0.16); +} + +.ops-status-pill.stopped, +.ops-status-pill.exited { + border-color: #d28686; + background: rgba(209, 75, 75, 0.16); +} + +.ops-status-pill.error { + border-color: #d14b4b; + background: rgba(209, 75, 75, 0.2); +} + +.ops-status-pill.idle { + border-color: #4b79d4; + background: rgba(47, 105, 226, 0.16); +} + +.ops-chat-frame { + position: relative; + min-height: 0; + display: grid; + grid-template-rows: 1fr auto; + gap: 10px; +} + +.ops-chat-frame.is-disabled .ops-chat-scroll, +.ops-chat-frame.is-disabled .ops-composer { + filter: grayscale(0.2) opacity(0.75); +} + +.ops-chat-disabled-mask { + position: absolute; + inset: 0; + border-radius: 12px; + background: color-mix(in oklab, var(--panel) 60%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 3; +} + +.ops-chat-disabled-card { + border: 1px solid color-mix(in oklab, var(--line) 78%, var(--brand) 22%); + border-radius: 10px; + background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%); + color: var(--text); + font-size: 13px; + font-weight: 700; + padding: 10px 12px; +} + +.ops-midstate-board { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); + padding: 10px; + display: grid; + gap: 8px; +} + +.ops-midstate-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ops-midstate-feed { + display: grid; + gap: 6px; + max-height: 180px; + overflow: auto; +} + +.ops-midstate-item { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + padding: 6px 8px; + display: grid; + grid-template-columns: 86px 1fr auto; + gap: 8px; + align-items: center; +} + +.ops-midstate-text { + font-size: 12px; + line-height: 1.35; + color: var(--text); +} + +.ops-chat-scroll { + min-height: 440px; + max-height: 68vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-soft); + padding: 14px; +} + +.ops-chat-row { + display: flex; + width: 100%; + margin-bottom: 12px; +} + +.ops-chat-item { + display: flex; + align-items: flex-end; + gap: 10px; + width: 100%; + min-width: 0; +} + +.ops-chat-item.is-user { + justify-content: flex-end; +} + +.ops-chat-item.is-assistant { + justify-content: flex-start; +} + +.ops-chat-row.is-user { + justify-content: flex-end; +} + +.ops-chat-row.is-assistant { + justify-content: flex-start; +} + +.ops-chat-bubble { + width: auto; + min-width: 0; + flex: 0 1 auto; + max-width: min(860px, calc(100% - 52px)); + border-radius: 14px; + border: 1px solid var(--line); + padding: 10px 12px; +} + +.ops-chat-bubble.assistant { + border-color: #3661aa; + background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%); +} + +.ops-chat-bubble.assistant.progress { + border-style: dashed; + border-color: color-mix(in oklab, var(--brand) 55%, var(--line) 45%); + background: color-mix(in oklab, var(--brand-soft) 35%, var(--panel-soft) 65%); +} + +.ops-chat-bubble.user { + border-color: #2f8f7f; + background: color-mix(in oklab, #d9fff0 36%, var(--panel-soft) 64%); +} + +.ops-chat-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 11px; + font-weight: 700; + margin-bottom: 6px; + color: var(--muted); +} + +.ops-chat-text { + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.58; + font-size: 14px; + color: var(--text); +} + +.ops-chat-text > *:first-child { + margin-top: 0; +} + +.ops-chat-text > *:last-child { + margin-bottom: 0; +} + +.ops-chat-text p, +.ops-chat-text ul, +.ops-chat-text ol { + margin: 8px 0; +} + +.ops-chat-text pre { + margin: 8px 0; + overflow: auto; + border-radius: 10px; + border: 1px solid color-mix(in oklab, var(--line) 75%, transparent); + background: color-mix(in oklab, var(--panel) 78%, #000 22%); + padding: 10px 12px; +} + +.ops-chat-text code { + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + word-break: break-word; +} + +.ops-chat-attachments { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ops-attach-link { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 12px; + text-decoration: none; + color: var(--text); + background: color-mix(in oklab, var(--panel) 78%, transparent); +} + +.ops-attach-link:hover { + border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%); +} + +.ops-avatar { + width: 34px; + height: 34px; + border-radius: 999px; + border: 1px solid var(--line); + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 34px; + overflow: hidden; +} + +.ops-avatar.bot { + background: #102d63; + border-color: #4e70ad; +} + +.ops-avatar.bot img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ops-avatar.user { + background: #1f4f8a; + border-color: #6d98cf; + color: #e9f2ff; +} + +.ops-chat-empty { + border: 1px dashed var(--line); + border-radius: 12px; + padding: 12px; + color: var(--muted); + font-size: 14px; +} + +.ops-thinking-bubble { + border: 1px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%); + background: color-mix(in oklab, var(--brand-soft) 40%, var(--panel-soft) 60%); + border-radius: 14px; + padding: 10px 12px; + min-width: 190px; +} + +.ops-thinking-cloud { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ops-thinking-cloud .dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: color-mix(in oklab, var(--brand) 75%, #fff 25%); + animation: ops-thinking-pulse 1.4s infinite ease-in-out; +} + +.ops-thinking-cloud .dot:nth-child(2) { + animation-delay: 0.2s; +} + +.ops-thinking-cloud .dot:nth-child(3) { + animation-delay: 0.4s; +} + +.ops-thinking-text { + margin-top: 8px; + color: var(--subtitle); + font-size: 12px; + font-weight: 700; +} + +@keyframes ops-thinking-pulse { + 0%, 80%, 100% { + transform: translateY(0); + opacity: 0.45; + } + 40% { + transform: translateY(-4px); + opacity: 1; + } +} + +@keyframes ops-control-dot { + 0%, 80%, 100% { + transform: translateY(0); + opacity: 0.35; + } + 40% { + transform: translateY(-2px); + opacity: 1; + } +} + +.ops-composer { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); + padding: 10px; + display: grid; + grid-template-columns: 1fr auto auto; + gap: 10px; + align-items: flex-end; +} + +.ops-composer-input { + min-height: 78px; + resize: none; + font-size: 14px; + line-height: 1.45; +} + +.ops-pending-files { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ops-pending-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + color: var(--text); + background: color-mix(in oklab, var(--panel-soft) 78%, transparent); +} + +.ops-chip-remove { + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + border-radius: 999px; + color: var(--muted); +} + +.ops-runtime-panel { + min-width: 0; + min-height: 0; +} + +.ops-runtime-shell { + display: grid; + grid-template-rows: auto 1fr; + gap: 10px; + min-height: 0; + height: 100%; +} + +.ops-runtime-head { + flex-wrap: wrap; + align-items: center; +} + +.ops-panel-tools { + position: relative; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} + +.ops-more-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 190px; + z-index: 30; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + box-shadow: 0 14px 32px rgba(9, 16, 31, 0.28); + padding: 6px; + display: grid; + gap: 4px; +} + +.ops-more-item { + width: 100%; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + padding: 8px 10px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.ops-more-item:hover { + background: color-mix(in oklab, var(--brand-soft) 36%, var(--panel-soft) 64%); + border-color: color-mix(in oklab, var(--brand) 48%, var(--line) 52%); +} + +.ops-more-item.danger { + color: #d76666; +} + +.ops-more-item.danger:hover { + background: rgba(215, 102, 102, 0.14); + border-color: rgba(215, 102, 102, 0.34); +} + +.ops-runtime-scroll { + min-height: 0; + overflow: auto; + display: grid; + gap: 10px; + align-content: start; + padding-right: 2px; +} + +.ops-runtime-card { + display: grid; + gap: 8px; +} + +.ops-runtime-state-card { + min-height: 0; +} + +.ops-runtime-state-card:not(.is-visual) { + min-height: 210px; + align-content: start; +} + +.ops-runtime-state-card.is-visual { + min-height: 210px; + padding: 0; + gap: 0; + overflow: hidden; +} + +.ops-runtime-state-card.is-visual .ops-state-stage { + height: 100%; + min-height: 100%; + border-radius: inherit; +} + +.ops-runtime-state-card.is-visual .ops-state-face { + width: 210px; + height: 140px; + gap: 34px; +} + +.ops-runtime-state-card.is-visual .ops-state-eye { + width: 30px; + height: 30px; +} + +.ops-runtime-state-card.is-visual .ops-state-eye::after { + width: 10px; + height: 10px; +} + +.ops-runtime-state-card.is-visual .ops-state-idle .ops-state-eye { + width: 36px; + height: 6px; +} + +.ops-runtime-state-card.is-visual .ops-state-caption { + bottom: 14px; + font-size: 12px; +} + +.ops-runtime-state-card.is-visual .ops-state-float { + padding: 6px; + right: 22%; + top: 24%; +} + +.ops-runtime-mode-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ops-runtime-mode-toggle { + display: inline-flex; + gap: 6px; +} + +.ops-runtime-mode-toggle .btn.active { + border-color: color-mix(in oklab, var(--brand) 70%, var(--line) 30%); + background: color-mix(in oklab, var(--brand-soft) 58%, var(--panel-soft) 42%); +} + +.ops-state-stage { + position: relative; + height: 160px; + border: 0; + border-radius: 10px; + display: grid; + place-items: center; + background: + radial-gradient(circle at 20% 25%, rgba(95, 136, 210, 0.12), transparent 40%), + radial-gradient(circle at 82% 70%, rgba(53, 94, 176, 0.1), transparent 40%), + color-mix(in oklab, var(--panel-soft) 72%, var(--panel) 28%); + overflow: hidden; +} + +.ops-state-model { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + max-width: calc(100% - 28px); + border: 1px solid rgba(148, 179, 235, 0.55); + border-radius: 999px; + background: rgba(11, 24, 48, 0.58); + color: #dce9ff; + font-size: 11px; + font-weight: 700; + padding: 3px 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ops-state-face { + width: 148px; + height: 96px; + border-radius: 999px; + border: 1px solid rgba(148, 179, 235, 0.55); + background: rgba(19, 37, 74, 0.55); + box-shadow: + 0 8px 30px rgba(10, 22, 44, 0.35), + inset 0 0 0 1px rgba(156, 185, 241, 0.18); + display: flex; + align-items: center; + justify-content: center; + gap: 26px; +} + +.ops-state-eye { + width: 22px; + height: 22px; + position: relative; + border-radius: 999px; + border: 3px solid #d8e7ff; + background: rgba(232, 242, 255, 0.95); + box-shadow: 0 0 0 1px rgba(145, 178, 234, 0.35); +} + +.ops-state-eye::after { + content: ""; + position: absolute; + width: 8px; + height: 8px; + border-radius: 999px; + background: #1b3569; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.ops-state-eye::before { + content: ""; + display: none; +} + +.ops-state-caption { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + letter-spacing: 0.08em; + color: color-mix(in oklab, var(--text) 70%, var(--muted) 30%); +} + +.ops-state-float { + position: absolute; + color: #f2f7ff; + border: 1px solid rgba(163, 187, 226, 0.65); + background: rgba(17, 36, 71, 0.72); + border-radius: 999px; + padding: 4px; + right: 24%; + top: 26%; +} + +.ops-state-float.state-thinking { + animation: ops-state-orbit 1.8s linear infinite; +} + +.ops-state-float.state-tool { + animation: ops-tool-bob 0.7s ease-in-out infinite; +} + +.ops-state-float.state-success { + color: #62e2ad; + border-color: rgba(98, 226, 173, 0.6); +} + +.ops-state-float.state-error { + color: #ff9b9b; + border-color: rgba(255, 155, 155, 0.6); +} + +.ops-state-idle .ops-state-eye { + width: 28px; + height: 4px; + border: 0; + border-radius: 999px; + background: #d8e7ff; + box-shadow: none; +} + +.ops-state-idle .ops-state-eye::after { + display: none; +} + +.ops-state-thinking .ops-state-face { + animation: ops-state-breathe 1.25s ease-in-out infinite; +} + +.ops-state-thinking .ops-state-eye::after { + animation: ops-eye-scan 1.3s ease-in-out infinite; +} + +.ops-state-tool_call .ops-state-face { + animation: ops-state-breathe 0.8s ease-in-out infinite; +} + +.ops-state-tool_call .ops-state-eye::after { + animation: ops-eye-scan-fast 0.7s ease-in-out infinite; +} + +.ops-state-success .ops-state-face { + box-shadow: + 0 8px 30px rgba(11, 48, 37, 0.3), + inset 0 0 0 1px rgba(112, 225, 172, 0.35); +} + +.ops-state-success .ops-state-eye { + width: 22px; + height: 12px; + border: 0; + border-top: 3px solid #78dfb4; + border-radius: 999px; + background: transparent; + box-shadow: none; + transform: translateY(3px); +} + +.ops-state-success .ops-state-eye.left { + transform: translateY(3px) rotate(-8deg); +} + +.ops-state-success .ops-state-eye.right { + transform: translateY(3px) rotate(8deg); +} + +.ops-state-success .ops-state-eye::after { + display: none; +} + +.ops-state-error .ops-state-face { + box-shadow: + 0 8px 30px rgba(57, 19, 19, 0.32), + inset 0 0 0 1px rgba(255, 155, 155, 0.32); +} + +.ops-state-error .ops-state-eye { + width: 20px; + height: 20px; + border: 0; + background: transparent; + box-shadow: none; +} + +.ops-state-error .ops-state-eye::after, +.ops-state-error .ops-state-eye::before { + content: ""; + display: block; + position: absolute; + left: 50%; + top: 50%; + width: 18px; + height: 3px; + border-radius: 999px; + background: #ff9b9b; +} + +.ops-state-error .ops-state-eye::after { + transform: translate(-50%, -50%) rotate(45deg); +} + +.ops-state-error .ops-state-eye::before { + transform: translate(-50%, -50%) rotate(-45deg); +} + +.ops-state-error .ops-state-face, +.ops-state-stopped .ops-state-face, +.ops-state-exited .ops-state-face { + filter: saturate(0.75); +} + +.ops-state-stopped .ops-state-eye, +.ops-state-exited .ops-state-eye { + width: 24px; + height: 3px; + border: 0; + background: color-mix(in oklab, var(--muted) 68%, #9fb4dc 32%); + box-shadow: none; +} + +.ops-state-stopped .ops-state-eye::after, +.ops-state-exited .ops-state-eye::after { + display: none; +} + +.ops-state-unknown .ops-state-eye, +.ops-state-info .ops-state-eye { + animation: none; +} + +@keyframes ops-state-breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +@keyframes ops-eye-scan { + 0%, 100% { transform: translate(-50%, -50%) translateX(0); } + 30% { transform: translate(-50%, -50%) translateX(-2px); } + 65% { transform: translate(-50%, -50%) translateX(2px); } +} + +@keyframes ops-eye-scan-fast { + 0%, 100% { transform: translate(-50%, -50%) translateX(0); } + 50% { transform: translate(-50%, -50%) translateX(2px); } +} + +@keyframes ops-state-orbit { + 0% { transform: rotate(0deg) translateX(20px) rotate(0deg); } + 100% { transform: rotate(360deg) translateX(20px) rotate(-360deg); } +} + +@keyframes ops-tool-bob { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(-15deg) translateY(-1px); } +} + +.ops-cron-list { + display: grid; + gap: 8px; +} + +.ops-cron-item { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in oklab, var(--panel-soft) 82%, transparent); + padding: 8px; +} + +.ops-cron-main { + min-width: 0; + display: grid; + gap: 4px; +} + +.ops-cron-name { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + color: var(--text); +} + +.ops-cron-meta { + font-size: 12px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-cron-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ops-runtime-row { + display: grid; + grid-template-columns: 90px 1fr; + gap: 8px; + align-items: start; + color: var(--text); + font-size: 13px; +} + +.ops-runtime-row span { + color: var(--subtitle); + font-weight: 700; +} + +.ops-preview { + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 10px; + background: var(--panel); + white-space: pre-wrap; + line-height: 1.5; + max-height: 140px; + overflow: auto; +} + +.ops-event-list { + display: grid; + gap: 8px; + max-height: 240px; + overflow: auto; +} + +.ops-event-item { + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px; + display: grid; + grid-template-columns: 86px 1fr auto; + gap: 8px; + align-items: center; + background: var(--panel); +} + +.ops-event-state { + font-size: 11px; + font-weight: 800; + border-radius: 999px; + border: 1px solid; + padding: 2px 8px; + text-align: center; +} + +.ops-event-state.thinking { + color: #61a0ff; + border-color: #5c88d8; + background: rgba(95, 146, 229, 0.15); +} + +.ops-event-state.tool_call { + color: #4fd1b0; + border-color: #3ca98f; + background: rgba(60, 169, 143, 0.16); +} + +.ops-event-state.success { + color: #4fc790; + border-color: #2ca476; + background: rgba(44, 164, 118, 0.16); +} + +.ops-event-state.error { + color: #e56a6a; + border-color: #cf5555; + background: rgba(209, 75, 75, 0.2); +} + +.ops-event-state.info { + color: #7ea3e6; + border-color: #5e7fb8; + background: rgba(94, 127, 184, 0.16); +} + +.ops-event-text { + font-size: 12px; + color: var(--text); + line-height: 1.4; +} + +.ops-event-time { + font-size: 11px; + color: var(--subtitle); +} + +.ops-empty-inline { + color: var(--muted); + font-size: 13px; + padding: 4px 2px; +} + +.ops-action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.workspace-panel { + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + min-height: 340px; + display: grid; + grid-template-rows: 1fr auto; +} + +.workspace-toolbar { + display: flex; + gap: 8px; + align-items: center; +} + +.workspace-refresh-icon-btn { + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel-soft); + color: var(--icon); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, transform 0.15s ease; +} + +.workspace-refresh-icon-btn:hover { + border-color: color-mix(in oklab, var(--brand) 60%, var(--line) 40%); + background: color-mix(in oklab, var(--brand-soft) 42%, var(--panel-soft) 58%); +} + +.workspace-refresh-icon-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.workspace-auto-switch { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--subtitle); + font-size: 11px; + font-weight: 700; + user-select: none; +} + +.workspace-auto-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.workspace-auto-switch-track { + position: relative; + width: 34px; + height: 20px; + border-radius: 999px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--panel-soft) 85%, var(--panel) 15%); + transition: background 0.2s ease, border-color 0.2s ease; +} + +.workspace-auto-switch-track::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: color-mix(in oklab, var(--text) 75%, #fff 25%); + transition: transform 0.2s ease, background 0.2s ease; +} + +.workspace-auto-switch input:checked + .workspace-auto-switch-track { + border-color: color-mix(in oklab, var(--brand) 70%, var(--line) 30%); + background: color-mix(in oklab, var(--brand) 42%, var(--panel-soft) 58%); +} + +.workspace-auto-switch input:checked + .workspace-auto-switch-track::after { + transform: translateX(14px); + background: #fff; +} + +.workspace-path { + border: 1px solid var(--line); + border-radius: 8px; + padding: 6px 8px; + font-size: 11px; + color: var(--muted); + background: var(--panel-soft); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.workspace-list { + background: var(--panel); + overflow: auto; + padding: 8px; +} + +.workspace-entry { + width: 100%; + border: 1px solid transparent; + border-radius: 8px; + background: color-mix(in oklab, var(--panel-soft) 70%, transparent); + color: var(--text); + text-align: left; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 8px; + padding-right: 8px; + margin-bottom: 6px; +} + +.workspace-entry .workspace-entry-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-entry .workspace-entry-meta { + color: var(--muted); + font-size: 11px; +} + +.workspace-entry.dir { + border-color: color-mix(in oklab, var(--line) 80%, transparent); +} + +.workspace-entry.nav-up { + border-style: dashed; +} + +.workspace-entry.file { + cursor: pointer; + border-color: color-mix(in oklab, var(--line) 80%, transparent); +} + +.workspace-entry:hover { + border-color: color-mix(in oklab, var(--brand) 65%, var(--line) 35%); + background: color-mix(in oklab, var(--brand-soft) 45%, transparent); +} + +.workspace-entry.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.workspace-hint { + background: var(--panel-soft); + color: var(--muted); + font-size: 12px; + font-weight: 600; + border-bottom: 1px solid var(--line); + padding: 7px 9px; +} + +.modal-preview { + width: min(1080px, 95vw); +} + +.workspace-preview-body { + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%); + min-height: 52vh; + max-height: 72vh; + overflow: auto; +} + +.workspace-preview-embed { + width: 100%; + min-height: 68vh; + border: 0; + background: #fff; +} + +.workspace-preview-body pre { + margin: 0; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.56; + color: var(--text); + font-size: 13px; + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.workspace-preview-body.markdown pre { + margin: 0; +} + +.workspace-markdown { + padding: 12px 14px; + color: var(--text); + line-height: 1.72; + font-size: 14px; + font-family: 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +.workspace-markdown > *:first-child { + margin-top: 0; +} + +.workspace-markdown > *:last-child { + margin-bottom: 0; +} + +.workspace-markdown h1, +.workspace-markdown h2, +.workspace-markdown h3, +.workspace-markdown h4 { + margin: 16px 0 8px; + color: var(--text); + font-weight: 800; + line-height: 1.3; +} + +.workspace-markdown h1 { + font-size: 24px; +} + +.workspace-markdown h2 { + font-size: 20px; +} + +.workspace-markdown h3 { + font-size: 17px; +} + +.workspace-markdown p { + margin: 8px 0; +} + +.workspace-markdown ul, +.workspace-markdown ol { + margin: 8px 0 8px 20px; + padding: 0; +} + +.workspace-markdown li { + margin: 4px 0; +} + +.workspace-markdown blockquote { + margin: 10px 0; + padding: 8px 12px; + border-left: 3px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%); + background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%); + border-radius: 8px; +} + +.workspace-markdown hr { + border: 0; + border-top: 1px solid var(--line); + margin: 14px 0; +} + +.workspace-markdown code { + font-family: 'SF Mono', Menlo, Consolas, monospace; + background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%); + border: 1px solid var(--line); + border-radius: 6px; + padding: 1px 6px; + font-size: 12px; +} + +.workspace-markdown pre { + margin: 10px 0; + padding: 10px 12px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; + background: color-mix(in oklab, var(--panel-soft) 70%, var(--panel) 30%); + border: 1px solid var(--line); + border-radius: 8px; +} + +.workspace-markdown pre code { + border: none; + background: transparent; + padding: 0; + font-size: 12px; +} + +.workspace-markdown table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; + font-size: 13px; +} + +.workspace-markdown th, +.workspace-markdown td { + border: 1px solid var(--line); + padding: 6px 8px; + text-align: left; +} + +.workspace-markdown th { + background: color-mix(in oklab, var(--panel-soft) 74%, var(--panel) 26%); +} + +.workspace-markdown a { + color: color-mix(in oklab, var(--brand) 80%, #5fa6ff 20%); + text-decoration: underline; +} + +.workspace-preview-meta { + color: var(--muted); + font-size: 11px; +} + +.app-shell[data-theme='dark'] .ops-chat-bubble.assistant { + background: #112850; +} + +.app-shell[data-theme='dark'] .ops-chat-bubble.user { + background: #0f2f35; +} + +.app-shell[data-theme='light'] .ops-chat-bubble.assistant { + background: #eaf1ff; + border-color: #a9c1ee; +} + +.app-shell[data-theme='light'] .ops-chat-bubble.user { + background: #e8f6f2; + border-color: #9ccfc2; +} + +.app-shell[data-theme='light'] .ops-avatar.bot { + background: #e6efff; + border-color: #a5bfe8; +} + +.app-shell[data-theme='light'] .ops-avatar.user { + background: #edf4ff; + border-color: #b1c7ea; + color: #274a7d; +} + +.app-shell[data-theme='light'] .ops-chat-scroll, +.app-shell[data-theme='light'] .ops-chat-head, +.app-shell[data-theme='light'] .ops-composer, +.app-shell[data-theme='light'] .ops-bot-card, +.app-shell[data-theme='light'] .ops-runtime-card { + background: #f7fbff; +} + +.app-shell[data-theme='light'] .ops-preview, +.app-shell[data-theme='light'] .ops-status-pill { + background: #ffffff; +} + +.app-shell[data-theme='light'] .workspace-list { + background: #ffffff; +} + +.app-shell[data-theme='light'] .workspace-hint, +.app-shell[data-theme='light'] .workspace-path { + background: #f7fbff; +} + +@media (max-width: 1160px) { + .ops-chat-scroll { + min-height: 320px; + max-height: 55vh; + } + + .ops-chat-bubble { + max-width: 96%; + } + + .ops-composer { + grid-template-columns: 1fr; + } + + .workspace-panel { + min-height: 280px; + grid-template-rows: 1fr auto; + } + + .workspace-toolbar { + gap: 6px; + } + + .workspace-path { + flex: 1; + } + + .ops-runtime-state-card { + min-height: 0; + } + + .ops-runtime-state-card:not(.is-visual) { + min-height: 170px; + } + + .ops-runtime-state-card.is-visual { + min-height: 170px; + } + + .ops-runtime-state-card.is-visual .ops-state-stage { + min-height: 100%; + } + + .ops-runtime-state-card.is-visual .ops-state-face { + width: 172px; + height: 114px; + gap: 28px; + } + + .ops-runtime-state-card.is-visual .ops-state-eye { + width: 24px; + height: 24px; + } + + .ops-runtime-state-card.is-visual .ops-state-idle .ops-state-eye { + width: 30px; + height: 5px; + } +} diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx new file mode 100644 index 0000000..b17f266 --- /dev/null +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -0,0 +1,2281 @@ +import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; +import axios from 'axios'; +import { Activity, Boxes, Check, Clock3, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, MessageSquareText, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { APP_ENDPOINTS } from '../../config/env'; +import { useAppStore } from '../../store/appStore'; +import type { ChatMessage } from '../../types/bot'; +import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser'; +import nanobotLogo from '../../assets/nanobot-logo.png'; +import './BotDashboardModule.css'; +import { channelsZhCn } from '../../i18n/channels.zh-cn'; +import { channelsEn } from '../../i18n/channels.en'; +import { pickLocale } from '../../i18n'; +import { dashboardZhCn } from '../../i18n/dashboard.zh-cn'; +import { dashboardEn } from '../../i18n/dashboard.en'; +import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; + +interface BotDashboardModuleProps { + onOpenCreateWizard?: () => void; + onOpenImageFactory?: () => void; + forcedBotId?: string; + compactMode?: boolean; +} + +type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; +type WorkspaceNodeType = 'dir' | 'file'; +type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; +type RuntimeViewMode = 'visual' | 'text'; +type CompactPanelTab = 'chat' | 'runtime'; + +interface WorkspaceNode { + name: string; + path: string; + type: WorkspaceNodeType; + size?: number; + ext?: string; + mtime?: string; + children?: WorkspaceNode[]; +} + +interface WorkspaceTreeResponse { + bot_id: string; + root: string; + cwd: string; + parent: string | null; + entries: WorkspaceNode[]; +} + +interface WorkspaceFileResponse { + bot_id: string; + path: string; + size: number; + is_markdown: boolean; + truncated: boolean; + content: string; +} + +interface WorkspacePreviewState { + path: string; + content: string; + truncated: boolean; + ext: string; + isMarkdown: boolean; +} + +interface WorkspaceUploadResponse { + bot_id: string; + files: Array<{ name: string; path: string; size: number }>; +} + +interface CronJob { + id: string; + name: string; + enabled?: boolean; + schedule?: { + kind?: 'at' | 'every' | 'cron' | string; + atMs?: number; + everyMs?: number; + expr?: string; + tz?: string; + }; + payload?: { + message?: string; + channel?: string; + to?: string; + }; + state?: { + nextRunAtMs?: number; + lastRunAtMs?: number; + lastStatus?: string; + lastError?: string; + }; +} + +interface CronJobsResponse { + bot_id: string; + version: number; + jobs: CronJob[]; +} + +interface BotChannel { + id: string | number; + bot_id: string; + channel_type: ChannelType | string; + external_app_id: string; + app_secret: string; + internal_port: number; + is_active: boolean; + extra_config: Record; + locked?: boolean; +} + +interface WorkspaceSkillOption { + id: string; + name: string; + type: 'dir' | 'file' | string; + path: string; + size?: number | null; + mtime?: string; + description?: string; +} + +interface SkillUploadResponse { + status: string; + bot_id: string; + installed: string[]; + skills: WorkspaceSkillOption[]; +} + +type BotEnvParams = Record; + +const providerPresets: Record = { + openrouter: { + model: 'openai/gpt-4o-mini', + apiBase: 'https://openrouter.ai/api/v1', + note: { + 'zh-cn': 'OpenRouter 网关,模型示例 openai/gpt-4o-mini', + en: 'OpenRouter gateway, model example: openai/gpt-4o-mini', + }, + }, + dashscope: { + model: 'qwen-plus', + apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + note: { + 'zh-cn': '阿里云 DashScope(千问),模型示例 qwen-plus', + en: 'Alibaba DashScope (Qwen), model example: qwen-plus', + }, + }, + openai: { + model: 'gpt-4o-mini', + note: { + 'zh-cn': 'OpenAI 原生接口', + en: 'OpenAI native endpoint', + }, + }, + deepseek: { + model: 'deepseek-chat', + note: { + 'zh-cn': 'DeepSeek 原生接口', + en: 'DeepSeek native endpoint', + }, + }, + kimi: { + model: 'moonshot-v1-8k', + apiBase: 'https://api.moonshot.cn/v1', + note: { + 'zh-cn': 'Kimi(Moonshot)接口,模型示例 moonshot-v1-8k', + en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k', + }, + }, + minimax: { + model: 'MiniMax-Text-01', + apiBase: 'https://api.minimax.chat/v1', + note: { + 'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01', + en: 'MiniMax endpoint, model example: MiniMax-Text-01', + }, + }, +}; +const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack']; + +function formatClock(ts: number) { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} + +function stateLabel(s?: string) { + return (s || 'IDLE').toUpperCase(); +} + +function normalizeRuntimeState(s?: string) { + const raw = stateLabel(s); + if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR'; + if (raw.includes('TOOL') || raw.includes('EXEC') || raw.includes('ACTION')) return 'TOOL_CALL'; + if (raw.includes('THINK') || raw.includes('PLAN') || raw.includes('REASON') || raw === 'RUNNING') return 'THINKING'; + if (raw.includes('SUCCESS') || raw.includes('DONE') || raw.includes('COMPLETE')) return 'SUCCESS'; + if (raw.includes('IDLE') || raw.includes('STOP')) return 'IDLE'; + return raw; +} + +function isPreviewableWorkspaceFile(node: WorkspaceNode) { + if (node.type !== 'file') return false; + const ext = (node.ext || '').toLowerCase(); + return ['.md', '.json', '.log', '.txt', '.csv', '.pdf'].includes(ext); +} + +function isPdfPath(path: string) { + return String(path || '').trim().toLowerCase().endsWith('.pdf'); +} + +function normalizeAttachmentPaths(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw + .map((v) => String(v || '').trim().replace(/\\/g, '/')) + .filter((v) => v.length > 0); +} + +function normalizeDashboardAttachmentPath(path: string): string { + const v = String(path || '').trim().replace(/\\/g, '/'); + if (!v) return ''; + const prefix = '/root/.nanobot/workspace/'; + if (v.startsWith(prefix)) return v.slice(prefix.length); + return v.replace(/^\/+/, ''); +} + +function isExternalHttpLink(href: string): boolean { + return /^https?:\/\//i.test(String(href || '').trim()); +} + +function mergeConversation(messages: ChatMessage[]) { + const merged: ChatMessage[] = []; + messages + .filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0)) + .forEach((msg) => { + const cleanText = msg.role === 'user' ? normalizeUserMessageText(msg.text) : normalizeAssistantMessageText(msg.text); + const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean); + if (!cleanText && attachments.length === 0) return; + const last = merged[merged.length - 1]; + if (last && last.role === msg.role) { + const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text); + const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText); + const lastKind = last.kind || 'final'; + const currentKind = msg.kind || 'final'; + const sameAttachmentSet = + JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments); + if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) { + last.ts = msg.ts; + return; + } + } + merged.push({ ...msg, text: cleanText, attachments }); + }); + return merged.slice(-120); +} + +function clampTemperature(value: number) { + if (Number.isNaN(value)) return 0.2; + return Math.min(1, Math.max(0, value)); +} + +function formatCronSchedule(job: CronJob, isZh: boolean) { + const s = job.schedule || {}; + if (s.kind === 'every' && Number(s.everyMs) > 0) { + const sec = Math.round(Number(s.everyMs) / 1000); + return isZh ? `每 ${sec}s` : `every ${sec}s`; + } + if (s.kind === 'cron') { + if (s.tz) return `${s.expr || '-'} (${s.tz})`; + return s.expr || '-'; + } + if (s.kind === 'at' && Number(s.atMs) > 0) { + return new Date(Number(s.atMs)).toLocaleString(); + } + return '-'; +} + +export function BotDashboardModule({ + onOpenCreateWizard, + onOpenImageFactory, + forcedBotId, + compactMode = false, +}: BotDashboardModuleProps) { + const { + activeBots, + setBots, + updateBotStatus, + locale, + addBotMessage, + setBotMessages, + } = useAppStore(); + const { notify, confirm } = useLucentPrompt(); + const [selectedBotId, setSelectedBotId] = useState(''); + const [command, setCommand] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [showBaseModal, setShowBaseModal] = useState(false); + const [showParamModal, setShowParamModal] = useState(false); + const [showChannelModal, setShowChannelModal] = useState(false); + const [showSkillsModal, setShowSkillsModal] = useState(false); + const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); + const [showCronModal, setShowCronModal] = useState(false); + const [showAgentModal, setShowAgentModal] = useState(false); + const [agentTab, setAgentTab] = useState('AGENTS'); + const [isTestingProvider, setIsTestingProvider] = useState(false); + const [providerTestResult, setProviderTestResult] = useState(''); + const [operatingBotId, setOperatingBotId] = useState(null); + const [sendingByBot, setSendingByBot] = useState>({}); + const [controlStateByBot, setControlStateByBot] = useState>({}); + const chatBottomRef = useRef(null); + const [workspaceEntries, setWorkspaceEntries] = useState([]); + const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [workspaceError, setWorkspaceError] = useState(''); + const [workspaceCurrentPath, setWorkspaceCurrentPath] = useState(''); + const [workspaceParentPath, setWorkspaceParentPath] = useState(null); + const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false); + const [workspacePreview, setWorkspacePreview] = useState(null); + const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true); + const [pendingAttachments, setPendingAttachments] = useState([]); + const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); + const filePickerRef = useRef(null); + const [cronJobs, setCronJobs] = useState([]); + const [cronLoading, setCronLoading] = useState(false); + const [cronActionJobId, setCronActionJobId] = useState(''); + const [channels, setChannels] = useState([]); + const [botSkills, setBotSkills] = useState([]); + const [isSkillUploading, setIsSkillUploading] = useState(false); + const skillZipPickerRef = useRef(null); + const [envParams, setEnvParams] = useState({}); + const [envDraftKey, setEnvDraftKey] = useState(''); + const [envDraftValue, setEnvDraftValue] = useState(''); + const [envDraftVisible, setEnvDraftVisible] = useState(false); + const [envVisibleByKey, setEnvVisibleByKey] = useState>({}); + const [isSavingChannel, setIsSavingChannel] = useState(false); + const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false); + const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ + sendProgress: false, + sendToolHints: false, + }); + const [newChannelType, setNewChannelType] = useState('feishu'); + const [runtimeViewMode, setRuntimeViewMode] = useState('visual'); + const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); + const [compactPanelTab, setCompactPanelTab] = useState('chat'); + const [isCompactMobile, setIsCompactMobile] = useState(false); + const runtimeMenuRef = useRef(null); + const markdownComponents = useMemo( + () => ({ + a: ({ href, children, ...props }: AnchorHTMLAttributes) => { + const link = String(href || '').trim(); + if (isExternalHttpLink(link)) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + }), + [], + ); + + const [editForm, setEditForm] = useState({ + name: '', + llm_provider: '', + llm_model: '', + image_tag: '', + api_key: '', + api_base: '', + temperature: 0.2, + top_p: 1, + max_tokens: 8192, + agents_md: '', + soul_md: '', + user_md: '', + tools_md: '', + identity_md: '', + }); + + const bots = useMemo(() => Object.values(activeBots), [activeBots]); + const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; + const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]); + const messages = selectedBot?.messages || []; + const events = selectedBot?.events || []; + const isZh = locale === 'zh'; + 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 runtimeMoreLabel = isZh ? '更多' : 'More'; + const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; + const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false; + const canChat = Boolean(selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState); + const isChatEnabled = Boolean(canChat && !isSending); + + const conversation = useMemo(() => mergeConversation(messages), [messages]); + const latestEvent = useMemo(() => [...events].reverse()[0], [events]); + const workspaceFiles = useMemo( + () => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v)), + [workspaceEntries], + ); + const addableChannelTypes = useMemo(() => { + const exists = new Set(channels.map((c) => String(c.channel_type).toLowerCase())); + return optionalChannelTypes.filter((t) => !exists.has(t)); + }, [channels]); + const envEntries = useMemo( + () => + Object.entries(envParams || {}) + .filter(([k]) => String(k || '').trim().length > 0) + .sort(([a], [b]) => a.localeCompare(b)), + [envParams], + ); + const lastUserTs = useMemo(() => [...conversation].reverse().find((m) => m.role === 'user')?.ts || 0, [conversation]); + const lastAssistantFinalTs = useMemo( + () => + [...conversation].reverse().find((m) => m.role === 'assistant' && (m.kind || 'final') !== 'progress')?.ts || 0, + [conversation], + ); + const isThinking = useMemo(() => { + if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false; + if (lastUserTs <= 0) return false; + if (lastAssistantFinalTs >= lastUserTs) return false; + // Keep showing running/thinking state until a final assistant reply arrives. + return true; + }, [selectedBot, lastUserTs, lastAssistantFinalTs]); + const displayState = useMemo(() => { + if (!selectedBot) return 'IDLE'; + const backendState = normalizeRuntimeState(selectedBot.current_state); + if (selectedBot.docker_status !== 'RUNNING') return backendState; + + if (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR') { + return backendState; + } + + if (isThinking) { + if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL'; + return 'THINKING'; + } + + if ( + latestEvent && + ['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) && + Date.now() - latestEvent.ts < 15000 + ) { + return latestEvent.state; + } + + if (latestEvent?.state === 'ERROR') return 'ERROR'; + return 'IDLE'; + }, [selectedBot, isThinking, latestEvent]); + const runtimeAction = useMemo(() => { + const action = summarizeProgressText(selectedBot?.last_action || '', isZh); + if (action && action !== t.processing) return action; + const eventText = summarizeProgressText(latestEvent?.text || '', isZh); + if (eventText && eventText !== t.processing) return eventText; + return '-'; + }, [selectedBot, latestEvent, isZh, t.processing]); + const conversationNodes = useMemo( + () => + conversation.map((item, idx) => ( +
+
+ {item.role !== 'user' && ( +
+ Nanobot +
+ )} + +
+
+ {item.role === 'user' ? t.you : 'Nanobot'} + {formatClock(item.ts)} +
+
+ {item.text ? ( + item.role === 'user' ? ( +
{normalizeUserMessageText(item.text)}
+ ) : ( + + {item.text} + + ) + ) : null} + {(item.attachments || []).length > 0 ? ( +
+ {(item.attachments || []).map((rawPath) => { + const filePath = normalizeDashboardAttachmentPath(rawPath); + const isPdf = isPdfPath(filePath); + const href = `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${isPdf ? '&download=1' : ''}`; + const filename = filePath.split('/').pop() || filePath; + return ( + + {filename} + + ); + })} +
+ ) : null} +
+
+ + {item.role === 'user' && ( +
+ +
+ )} +
+
+ )), + [conversation, selectedBotId, t.user, t.you], + ); + + useEffect(() => { + const forced = String(forcedBotId || '').trim(); + if (forced) { + if (activeBots[forced]) { + if (selectedBotId !== forced) setSelectedBotId(forced); + } else if (selectedBotId) { + setSelectedBotId(''); + } + return; + } + if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id); + if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id); + }, [bots, selectedBotId, activeBots, forcedBotId]); + + useEffect(() => { + chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [selectedBotId, conversation.length]); + + useEffect(() => { + const onPointerDown = (event: MouseEvent) => { + if (!runtimeMenuRef.current) return; + if (!runtimeMenuRef.current.contains(event.target as Node)) { + setRuntimeMenuOpen(false); + } + }; + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, []); + + useEffect(() => { + setRuntimeMenuOpen(false); + }, [selectedBotId]); + + useEffect(() => { + if (!compactMode) { + setIsCompactMobile(false); + setCompactPanelTab('chat'); + return; + } + const media = window.matchMedia('(max-width: 980px)'); + const apply = () => setIsCompactMobile(media.matches); + apply(); + media.addEventListener('change', apply); + return () => media.removeEventListener('change', apply); + }, [compactMode]); + + useEffect(() => { + if (!selectedBotId) return; + const bot = selectedBot; + if (!bot) return; + setProviderTestResult(''); + setEditForm({ + name: bot.name || '', + llm_provider: bot.llm_provider || 'dashscope', + llm_model: bot.llm_model || '', + image_tag: bot.image_tag || '', + api_key: '', + api_base: bot.api_base || '', + temperature: clampTemperature(bot.temperature ?? 0.2), + top_p: bot.top_p ?? 1, + max_tokens: bot.max_tokens ?? 8192, + 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 || '', + }); + setPendingAttachments([]); + }, [selectedBotId, selectedBot?.id]); + + useEffect(() => { + if (!selectedBotId || !selectedBot) { + setGlobalDelivery({ sendProgress: false, sendToolHints: false }); + return; + } + setGlobalDelivery({ + sendProgress: Boolean(selectedBot.send_progress), + sendToolHints: Boolean(selectedBot.send_tool_hints), + }); + }, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]); + + const refresh = async () => { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); + setBots(res.data); + }; + + const openWorkspaceFilePreview = async (path: string) => { + if (!selectedBotId || !path) return; + const normalizedPath = String(path || '').trim(); + if (isPdfPath(normalizedPath)) { + const href = `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(normalizedPath)}&download=1`; + window.open(href, '_blank', 'noopener,noreferrer'); + return; + } + setWorkspaceFileLoading(true); + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, { + params: { path, max_bytes: 400000 }, + }); + const filePath = res.data.path || path; + const textExt = (filePath.split('.').pop() || '').toLowerCase(); + let content = res.data.content || ''; + if (textExt === 'json') { + try { + content = JSON.stringify(JSON.parse(content), null, 2); + } catch { + // Keep original content when JSON is not strictly parseable. + } + } + setWorkspacePreview({ + path: filePath, + content, + truncated: Boolean(res.data.truncated), + ext: textExt ? `.${textExt}` : '', + isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), + }); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.fileReadFail; + notify(msg, { tone: 'error' }); + } finally { + setWorkspaceFileLoading(false); + } + }; + + const loadWorkspaceTree = async (botId: string, path: string = '') => { + if (!botId) return; + setWorkspaceLoading(true); + setWorkspaceError(''); + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, { + params: { path }, + }); + const entries = Array.isArray(res.data?.entries) ? res.data.entries : []; + setWorkspaceEntries(entries); + setWorkspaceCurrentPath(res.data?.cwd || ''); + setWorkspaceParentPath(res.data?.parent ?? null); + } catch (error: any) { + setWorkspaceEntries([]); + setWorkspaceCurrentPath(''); + setWorkspaceParentPath(null); + setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail); + } finally { + setWorkspaceLoading(false); + } + }; + + const loadChannels = async (botId: string) => { + if (!botId) return; + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`); + setChannels(Array.isArray(res.data) ? res.data : []); + }; + + const loadBotSkills = async (botId: string) => { + if (!botId) return; + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`); + setBotSkills(Array.isArray(res.data) ? res.data : []); + }; + + const loadBotEnvParams = async (botId: string) => { + if (!botId) return; + try { + const res = await axios.get<{ env_params?: Record }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`); + const rows = res.data?.env_params && typeof res.data.env_params === 'object' ? res.data.env_params : {}; + const next: BotEnvParams = {}; + Object.entries(rows).forEach(([k, v]) => { + const key = String(k || '').trim().toUpperCase(); + if (!key) return; + next[key] = String(v ?? ''); + }); + setEnvParams(next); + } catch { + setEnvParams({}); + } + }; + + const saveBotEnvParams = async () => { + if (!selectedBot) return; + try { + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/env-params`, { env_params: envParams }); + setShowEnvParamsModal(false); + notify(t.envParamsSaved, { tone: 'success' }); + } catch (error: any) { + notify(error?.response?.data?.detail || t.envParamsSaveFail, { tone: 'error' }); + } + }; + + const upsertEnvParam = (key: string, value: string) => { + const normalized = String(key || '').trim().toUpperCase(); + if (!normalized) return; + setEnvParams((prev) => ({ ...(prev || {}), [normalized]: String(value ?? '') })); + }; + + const removeEnvParam = (key: string) => { + const normalized = String(key || '').trim().toUpperCase(); + if (!normalized) return; + setEnvParams((prev) => { + const next = { ...(prev || {}) }; + delete next[normalized]; + return next; + }); + }; + + const removeBotSkill = async (skill: WorkspaceSkillOption) => { + if (!selectedBot) return; + const ok = await confirm({ + title: t.removeSkill, + message: t.toolsRemoveConfirm(skill.name || skill.id), + tone: 'warning', + }); + if (!ok) return; + try { + await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`); + await loadBotSkills(selectedBot.id); + } catch (error: any) { + notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' }); + } + }; + + const triggerSkillZipUpload = () => { + if (!selectedBot || isSkillUploading) return; + skillZipPickerRef.current?.click(); + }; + + const onPickSkillZip = async (event: ChangeEvent) => { + if (!selectedBot || !event.target.files || event.target.files.length === 0) return; + const file = event.target.files[0]; + const filename = String(file?.name || '').toLowerCase(); + if (!filename.endsWith('.zip')) { + notify(t.invalidZipFile, { tone: 'warning' }); + event.target.value = ''; + return; + } + const formData = new FormData(); + formData.append('file', file); + setIsSkillUploading(true); + try { + const res = await axios.post( + `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/upload`, + formData, + ); + const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : []; + setBotSkills(nextSkills); + } catch (error: any) { + notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' }); + } finally { + setIsSkillUploading(false); + event.target.value = ''; + } + }; + + const loadCronJobs = async (botId: string) => { + if (!botId) return; + setCronLoading(true); + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs`, { + params: { include_disabled: true }, + }); + setCronJobs(Array.isArray(res.data?.jobs) ? res.data.jobs : []); + } catch { + setCronJobs([]); + } finally { + setCronLoading(false); + } + }; + + const stopCronJob = async (jobId: string) => { + if (!selectedBot || !jobId) return; + setCronActionJobId(jobId); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}/stop`); + await loadCronJobs(selectedBot.id); + } catch (error: any) { + notify(error?.response?.data?.detail || t.cronStopFail, { tone: 'error' }); + } finally { + setCronActionJobId(''); + } + }; + + const deleteCronJob = async (jobId: string) => { + if (!selectedBot || !jobId) return; + const ok = await confirm({ + title: t.cronDelete, + message: t.cronDeleteConfirm(jobId), + tone: 'warning', + }); + if (!ok) return; + setCronActionJobId(jobId); + try { + await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}`); + await loadCronJobs(selectedBot.id); + } catch (error: any) { + notify(error?.response?.data?.detail || t.cronDeleteFail, { tone: 'error' }); + } finally { + setCronActionJobId(''); + } + }; + + const updateChannelLocal = (index: number, patch: Partial) => { + setChannels((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c))); + }; + + const saveChannel = async (channel: BotChannel) => { + if (!selectedBot) return; + setIsSavingChannel(true); + try { + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, { + channel_type: channel.channel_type, + external_app_id: channel.external_app_id, + app_secret: channel.app_secret, + internal_port: Number(channel.internal_port), + is_active: channel.is_active, + extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}), + }); + await loadChannels(selectedBot.id); + notify(t.channelSaved, { tone: 'success' }); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.channelSaveFail; + notify(msg, { tone: 'error' }); + } finally { + setIsSavingChannel(false); + } + }; + + const addChannel = async () => { + if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return; + setIsSavingChannel(true); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, { + channel_type: newChannelType, + is_active: true, + external_app_id: '', + app_secret: '', + internal_port: 8080, + extra_config: {}, + }); + await loadChannels(selectedBot.id); + const rest = addableChannelTypes.filter((t) => t !== newChannelType); + if (rest.length > 0) setNewChannelType(rest[0]); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.channelAddFail; + notify(msg, { tone: 'error' }); + } finally { + setIsSavingChannel(false); + } + }; + + const removeChannel = async (channel: BotChannel) => { + if (!selectedBot || channel.channel_type === 'dashboard') return; + const ok = await confirm({ + title: t.channels, + message: t.channelDeleteConfirm(channel.channel_type), + tone: 'warning', + }); + if (!ok) return; + setIsSavingChannel(true); + try { + await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`); + await loadChannels(selectedBot.id); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.channelDeleteFail; + notify(msg, { tone: 'error' }); + } finally { + setIsSavingChannel(false); + } + }; + + const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard'; + const sanitizeChannelExtra = (channelType: string, extra: Record) => { + const type = String(channelType || '').toLowerCase(); + if (type === 'dashboard') return extra || {}; + const next = { ...(extra || {}) }; + delete next.sendProgress; + delete next.sendToolHints; + return next; + }; + const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => { + setGlobalDelivery((prev) => ({ ...prev, [key]: value })); + }; + const saveGlobalDelivery = async () => { + if (!selectedBot) return; + setIsSavingGlobalDelivery(true); + try { + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}`, { + send_progress: Boolean(globalDelivery.sendProgress), + send_tool_hints: Boolean(globalDelivery.sendToolHints), + }); + await refresh(); + notify(t.channelSaved, { tone: 'success' }); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.channelSaveFail; + notify(msg, { tone: 'error' }); + } finally { + setIsSavingGlobalDelivery(false); + } + }; + + const renderChannelFields = (channel: BotChannel, idx: number) => { + const ctype = String(channel.channel_type).toLowerCase(); + if (ctype === 'telegram') { + return ( + <> + updateChannelLocal(idx, { app_secret: e.target.value })} /> + updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })} + /> + + + ); + } + + if (ctype === 'feishu') { + return ( + <> + updateChannelLocal(idx, { external_app_id: e.target.value })} /> + updateChannelLocal(idx, { app_secret: e.target.value })} /> + updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} /> + updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} /> + + ); + } + + if (ctype === 'dingtalk') { + return ( + <> + updateChannelLocal(idx, { external_app_id: e.target.value })} /> + updateChannelLocal(idx, { app_secret: e.target.value })} /> + + ); + } + + if (ctype === 'slack') { + return ( + <> + updateChannelLocal(idx, { external_app_id: e.target.value })} /> + updateChannelLocal(idx, { app_secret: e.target.value })} /> + + ); + } + + if (ctype === 'qq') { + return ( + <> + updateChannelLocal(idx, { external_app_id: e.target.value })} /> + updateChannelLocal(idx, { app_secret: e.target.value })} /> + + ); + } + + return null; + }; + + const stopBot = async (id: string, status: string) => { + if (status !== 'RUNNING') return; + setOperatingBotId(id); + setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' })); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`); + updateBotStatus(id, 'STOPPED'); + await refresh(); + } catch { + notify(t.stopFail, { tone: 'error' }); + } finally { + setOperatingBotId(null); + setControlStateByBot((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } + }; + + const startBot = async (id: string, status: string) => { + if (status === 'RUNNING') return; + setOperatingBotId(id); + setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' })); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`); + updateBotStatus(id, 'RUNNING'); + await refresh(); + } catch { + notify(t.startFail, { tone: 'error' }); + } finally { + setOperatingBotId(null); + setControlStateByBot((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } + }; + + const send = async () => { + if (!selectedBot || !canChat || isSending) return; + if (!command.trim() && pendingAttachments.length === 0) return; + const text = normalizeUserMessageText(command); + const payloadText = text || (pendingAttachments.length > 0 ? t.attachmentMessage : ''); + if (!payloadText && pendingAttachments.length === 0) return; + + try { + setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); + const res = await axios.post( + `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, + { command: payloadText, attachments: pendingAttachments }, + { timeout: 12000 }, + ); + if (!res.data?.success) { + throw new Error(t.backendDeliverFail); + } + setCommand(''); + setPendingAttachments([]); + } catch (error: any) { + const msg = error?.response?.data?.detail || error?.message || t.sendFail; + addBotMessage(selectedBot.id, { + role: 'assistant', + text: t.sendFailMsg(msg), + ts: Date.now(), + }); + notify(msg, { tone: 'error' }); + } finally { + setSendingByBot((prev) => { + const next = { ...prev }; + delete next[selectedBot.id]; + return next; + }); + } + }; + + const onComposerKeyDown = (e: KeyboardEvent) => { + const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number }; + if (native.isComposing || native.keyCode === 229) return; + const isEnter = e.key === 'Enter' || e.key === 'NumpadEnter'; + if (!isEnter || e.shiftKey) return; + e.preventDefault(); + void send(); + }; + + const triggerPickAttachments = () => { + if (!selectedBot || !canChat || isUploadingAttachments) return; + filePickerRef.current?.click(); + }; + + const onPickAttachments = async (event: ChangeEvent) => { + if (!selectedBot || !event.target.files || event.target.files.length === 0) return; + const files = Array.from(event.target.files); + 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, + ); + const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path)); + if (uploaded.length > 0) { + setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploaded]))); + await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath); + } + } catch (error: any) { + const msg = error?.response?.data?.detail || t.uploadFail; + notify(msg, { tone: 'error' }); + } finally { + setIsUploadingAttachments(false); + event.target.value = ''; + } + }; + + const onBaseProviderChange = (provider: string) => { + const preset = providerPresets[provider]; + setEditForm((p) => ({ + ...p, + llm_provider: provider, + llm_model: preset?.model || p.llm_model, + api_base: preset?.apiBase ?? p.api_base, + })); + setProviderTestResult(''); + }; + + const testProviderConnection = async () => { + if (!editForm.llm_provider || !editForm.llm_model || !editForm.api_key.trim()) { + notify(t.providerRequired, { tone: 'warning' }); + return; + } + setIsTestingProvider(true); + setProviderTestResult(''); + try { + const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, { + provider: editForm.llm_provider, + model: editForm.llm_model, + api_key: editForm.api_key.trim(), + api_base: editForm.api_base || undefined, + }); + if (res.data?.ok) { + const preview = (res.data.models_preview || []).slice(0, 3).join(', '); + setProviderTestResult(t.connOk(preview)); + } else { + setProviderTestResult(t.connFail(res.data?.detail || 'unknown error')); + } + } catch (error: any) { + const msg = error?.response?.data?.detail || error?.message || 'request failed'; + setProviderTestResult(t.connFail(msg)); + } finally { + setIsTestingProvider(false); + } + }; + + useEffect(() => { + if (!selectedBotId) { + setWorkspaceEntries([]); + setWorkspaceCurrentPath(''); + setWorkspaceParentPath(null); + setWorkspaceError(''); + setChannels([]); + setPendingAttachments([]); + setCronJobs([]); + setBotSkills([]); + setEnvParams({}); + return; + } + void loadWorkspaceTree(selectedBotId, ''); + void loadCronJobs(selectedBotId); + void loadBotSkills(selectedBotId); + void loadBotEnvParams(selectedBotId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBotId]); + + useEffect(() => { + if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return; + let stopped = false; + + const tick = async () => { + if (stopped) return; + await loadWorkspaceTree(selectedBotId, workspaceCurrentPath); + }; + + void tick(); + const timer = window.setInterval(() => { + void tick(); + }, 2000); + + return () => { + stopped = true; + window.clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]); + + const saveBot = async (mode: 'params' | 'agent' | 'base') => { + if (!selectedBot) return; + setIsSaving(true); + try { + const payload: Record = {}; + if (mode === 'base') { + payload.name = editForm.name; + payload.llm_provider = editForm.llm_provider; + payload.llm_model = editForm.llm_model; + payload.api_base = editForm.api_base; + 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); + } + if (mode === 'agent') { + payload.agents_md = editForm.agents_md; + payload.soul_md = editForm.soul_md; + payload.user_md = editForm.user_md; + payload.tools_md = editForm.tools_md; + payload.identity_md = editForm.identity_md; + } + + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}`, payload); + await refresh(); + setShowBaseModal(false); + setShowParamModal(false); + setShowAgentModal(false); + notify(t.configUpdated, { tone: 'success' }); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.saveFail; + notify(msg, { tone: 'error' }); + } finally { + setIsSaving(false); + } + }; + + const removeBot = async (botId?: string) => { + const targetId = botId || selectedBot?.id; + if (!targetId) return; + const ok = await confirm({ + title: t.delete, + message: t.deleteBotConfirm(targetId), + tone: 'warning', + }); + if (!ok) return; + try { + await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } }); + await refresh(); + if (selectedBotId === targetId) setSelectedBotId(''); + notify(t.deleteBotDone, { tone: 'success' }); + } catch { + notify(t.deleteFail, { tone: 'error' }); + } + }; + + const clearConversationHistory = async () => { + if (!selectedBot) return; + const target = selectedBot.name || selectedBot.id; + const ok = await confirm({ + title: t.clearHistory, + message: t.clearHistoryConfirm(target), + tone: 'warning', + }); + if (!ok) return; + try { + await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`); + setBotMessages(selectedBot.id, []); + notify(t.clearHistoryDone, { tone: 'success' }); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.clearHistoryFail; + notify(msg, { tone: 'error' }); + } + }; + + const exportConversationJson = () => { + if (!selectedBot) return; + try { + const payload = { + bot_id: selectedBot.id, + bot_name: selectedBot.name || selectedBot.id, + exported_at: new Date().toISOString(), + message_count: conversation.length, + messages: conversation.map((m) => ({ + role: m.role, + text: m.text, + attachments: m.attachments || [], + kind: m.kind || 'final', + ts: m.ts, + datetime: new Date(m.ts).toISOString(), + })), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${selectedBot.id}-conversation-${stamp}.json`; + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch { + notify(t.exportHistoryFail, { tone: 'error' }); + } + }; + + const tabMap: Record = { + AGENTS: 'agents_md', + SOUL: 'soul_md', + USER: 'user_md', + TOOLS: 'tools_md', + IDENTITY: 'identity_md', + }; + + const renderWorkspaceNodes = (nodes: WorkspaceNode[]): ReactNode[] => { + const rendered: ReactNode[] = []; + if (workspaceParentPath !== null) { + rendered.push( + , + ); + } + + nodes.forEach((node) => { + const key = `${node.type}:${node.path}`; + if (node.type === 'dir') { + rendered.push( + , + ); + return; + } + + const previewable = isPreviewableWorkspaceFile(node); + const pdfFile = String(node.ext || '').toLowerCase() === '.pdf'; + rendered.push( + , + ); + }); + return rendered; + }; + + return ( + <> +
+ {!compactMode ? ( +
+
+

{t.titleBots}

+
+ + +
+
+ +
+ {bots.map((bot) => { + const selected = selectedBotId === bot.id; + const controlState = controlStateByBot[bot.id]; + const isOperating = operatingBotId === bot.id; + const isStarting = controlState === 'starting'; + const isStopping = controlState === 'stopping'; + return ( +
setSelectedBotId(bot.id)}> +
+ ); + })} +
+
+ ) : null} + +
+ {selectedBot ? ( +
+
+
+ {conversation.length === 0 ? ( +
+ {t.noConversation} +
+ ) : ( + conversationNodes + )} + + {isThinking ? ( +
+
+
+ Nanobot +
+
+
+ + + +
+
{t.thinking}
+
+
+
+ ) : null} + +
+
+ +
+ +