v0.1.1
commit
d7a05651b7
|
|
@ -0,0 +1,17 @@
|
|||
.git
|
||||
.gitignore
|
||||
**/.DS_Store
|
||||
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/venv
|
||||
|
||||
data
|
||||
workspace
|
||||
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.pyd
|
||||
|
||||
*.log
|
||||
|
|
@ -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=
|
||||
|
|
@ -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*
|
||||
|
|
@ -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://<host>:${NGINX_PORT}`(默认 `8080`)
|
||||
|
||||
### 关键说明
|
||||
|
||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
||||
- `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径,并且在 `docker-compose.prod.yml` 中以“同路径”挂载到后端容器。
|
||||
原因:后端通过 Docker API 创建 Bot 容器时,使用的是宿主机可见的 bind 路径。
|
||||
|
|
@ -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- 领域: 运维与任务执行
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 状态来自日志与容器状态,不依赖侵入式埋点。
|
||||
|
|
@ -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` 对齐。
|
||||
|
|
@ -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 文件,确保配置一致性。
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/app-bot-icon.svg" />
|
||||
<link rel="shortcut icon" type="image/svg+xml" href="/app-bot-icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/app-bot-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard Nanobot</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect x="8" y="16" width="48" height="36" rx="8" stroke="#233f78" stroke-width="4"/>
|
||||
<path d="M32 8v8" stroke="#233f78" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="20" y="28" width="8" height="8" rx="2" fill="#233f78"/>
|
||||
<rect x="36" y="28" width="8" height="8" rx="2" fill="#233f78"/>
|
||||
<path d="M24 42h16" stroke="#233f78" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M8 34H4M60 34h-4" stroke="#233f78" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (
|
||||
<div className={`app-shell ${urlView.compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
||||
<div className="app-frame">
|
||||
<header className="app-header">
|
||||
<div className="row-between app-header-top">
|
||||
<div className="app-title">
|
||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
||||
<div>
|
||||
<h1>{t.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="global-switches">
|
||||
<div className="switch-compact">
|
||||
<button
|
||||
className={`switch-btn ${theme === 'dark' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('dark')}
|
||||
title={t.dark}
|
||||
aria-label={t.dark}
|
||||
>
|
||||
<MoonStar size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`switch-btn ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('light')}
|
||||
title={t.light}
|
||||
aria-label={t.light}
|
||||
>
|
||||
<SunMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="switch-compact">
|
||||
<button
|
||||
className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`}
|
||||
onClick={() => setLocale('zh')}
|
||||
title={t.zh}
|
||||
aria-label={t.zh}
|
||||
>
|
||||
<span>ZH</span>
|
||||
</button>
|
||||
<button
|
||||
className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`}
|
||||
onClick={() => setLocale('en')}
|
||||
title={t.en}
|
||||
aria-label={t.en}
|
||||
>
|
||||
<span>EN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main-stage">
|
||||
<BotDashboardModule
|
||||
onOpenCreateWizard={() => setShowCreateWizard(true)}
|
||||
onOpenImageFactory={() => setShowImageFactory(true)}
|
||||
forcedBotId={urlView.forcedBotId || undefined}
|
||||
compactMode={urlView.compactMode}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{!urlView.compactMode && showImageFactory && (
|
||||
<div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}>
|
||||
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="row-between">
|
||||
<h3>{t.nav.images.title}</h3>
|
||||
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowImageFactory(false)} title={t.close} aria-label={t.close}>
|
||||
<Boxes size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-modal-body">
|
||||
<ImageFactoryModule />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!urlView.compactMode && showCreateWizard && (
|
||||
<div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}>
|
||||
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="row-between">
|
||||
<h3>{t.nav.onboarding.title}</h3>
|
||||
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCreateWizard(false)} title={t.close} aria-label={t.close}>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-modal-body">
|
||||
<BotWizardModule
|
||||
onCreated={() => {
|
||||
setShowCreateWizard(false);
|
||||
}}
|
||||
onGoDashboard={() => setShowCreateWizard(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1 @@
|
|||
export { ManagementModule as BotManagementView } from '../modules/management/ManagementModule';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { CreateBotModal } from '../modules/management/components/CreateBotModal';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { KernelManagerModal } from '../modules/management/components/KernelManagerModal';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { DashboardModule as VisualDeckView } from '../modules/dashboard/DashboardModule';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { VoxelBot } from '../modules/dashboard/components/VoxelBot';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { WorkingDeck } from '../modules/dashboard/components/WorkingDeck';
|
||||
|
|
@ -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<boolean>;
|
||||
}
|
||||
|
||||
const LucentPromptContext = createContext<LucentPromptApi | null>(null);
|
||||
|
||||
function ToneIcon({ tone }: { tone: PromptTone }) {
|
||||
if (tone === 'success') return <CheckCircle2 size={16} />;
|
||||
if (tone === 'warning') return <AlertTriangle size={16} />;
|
||||
if (tone === 'error') return <AlertCircle size={16} />;
|
||||
return <Info size={16} />;
|
||||
}
|
||||
|
||||
export function LucentPromptProvider({ children }: { children: ReactNode }) {
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(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<boolean>((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<LucentPromptApi>(
|
||||
() => ({
|
||||
notify,
|
||||
confirm,
|
||||
}),
|
||||
[notify, confirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<LucentPromptContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
<div className="lucent-toast-stack" aria-live="polite" aria-atomic="false">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`lucent-toast tone-${toast.tone}`}>
|
||||
<span className="lucent-toast-icon">
|
||||
<ToneIcon tone={toast.tone} />
|
||||
</span>
|
||||
<div className="lucent-toast-body">
|
||||
{toast.title ? <div className="lucent-toast-title">{toast.title}</div> : null}
|
||||
<div className="lucent-toast-message">{toast.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{confirmState ? (
|
||||
<div className="lucent-confirm-mask" onClick={() => closeConfirm(false)}>
|
||||
<div className={`lucent-confirm-card tone-${confirmState.tone}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="lucent-confirm-head">
|
||||
<span className="lucent-confirm-icon">
|
||||
<ToneIcon tone={confirmState.tone} />
|
||||
</span>
|
||||
<div className="lucent-confirm-title">
|
||||
{confirmState.title || (locale === 'zh' ? '请确认操作' : 'Please Confirm')}
|
||||
</div>
|
||||
<button
|
||||
className="lucent-confirm-close"
|
||||
onClick={() => closeConfirm(false)}
|
||||
aria-label={locale === 'zh' ? '关闭' : 'Close'}
|
||||
title={locale === 'zh' ? '关闭' : 'Close'}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="lucent-confirm-message">{confirmState.message}</div>
|
||||
<div className="lucent-confirm-actions">
|
||||
<button className="btn btn-secondary" onClick={() => closeConfirm(false)}>
|
||||
{confirmState.cancelText}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => closeConfirm(true)}>
|
||||
{confirmState.confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</LucentPromptContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLucentPrompt() {
|
||||
const ctx = useContext(LucentPromptContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLucentPrompt must be used inside LucentPromptProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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<Record<string, WebSocket>>({});
|
||||
const heartbeatsRef = useRef<Record<string, number>>({});
|
||||
const lastUserEchoRef = useRef<Record<string, { text: string; ts: number }>>({});
|
||||
const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({});
|
||||
const hydratedMessagesRef = useRef<Record<string, boolean>>({});
|
||||
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<BotState[]>(`${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<any[]>(`${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 = {};
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# I18N Convention
|
||||
|
||||
Use this convention for every new page/module:
|
||||
|
||||
1. Add two language packs in this folder:
|
||||
- `<module>.zh-cn.ts`
|
||||
- `<module>.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.
|
||||
|
|
@ -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' },
|
||||
},
|
||||
};
|
||||
|
|
@ -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: '对话、状态、日志与启停' },
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export const botsSyncEn = {
|
||||
stateUpdated: 'state updated',
|
||||
replied: 'Replied',
|
||||
progress: 'progress',
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export const botsSyncZhCn = {
|
||||
stateUpdated: '状态已更新',
|
||||
replied: '已回复',
|
||||
progress: '处理中',
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}?`,
|
||||
};
|
||||
|
|
@ -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}?`,
|
||||
};
|
||||
|
|
@ -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.',
|
||||
};
|
||||
|
|
@ -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:* 镜像,请先手工构建。',
|
||||
};
|
||||
|
|
@ -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<T>(locale: AppLocale, packs: Record<UiLocale, T>): T {
|
||||
return packs[toUiLocale(locale)];
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export const legacyDeckZhCn = {
|
||||
operationsDeck: '运维控制台',
|
||||
sandbox: '神经网络沙盒',
|
||||
activeNodes: '活跃节点',
|
||||
globalEvents: '全局事件',
|
||||
listening: '等待指令',
|
||||
standby: '所有单元待命中,等待部署...',
|
||||
deckTitle: '数字员工工位',
|
||||
deckSub: '实时观察机器人执行状态与数据流转',
|
||||
idle: 'IDLE',
|
||||
};
|
||||
|
|
@ -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`.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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`。',
|
||||
},
|
||||
};
|
||||
|
|
@ -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...',
|
||||
};
|
||||
|
|
@ -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: '创建中...',
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<LucentPromptProvider>
|
||||
<App />
|
||||
</LucentPromptProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,79 @@
|
|||
import { Activity, Radio, Terminal } from 'lucide-react';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { WorkingDeck } from './components/WorkingDeck';
|
||||
import { pickLocale } from '../../i18n';
|
||||
import { legacyDeckZhCn } from '../../i18n/legacy-deck.zh-cn';
|
||||
import { legacyDeckEn } from '../../i18n/legacy-deck.en';
|
||||
|
||||
export function DashboardModule() {
|
||||
const activeBots = useAppStore((state) => state.activeBots);
|
||||
const locale = useAppStore((state) => state.locale);
|
||||
const t = pickLocale(locale, { 'zh-cn': legacyDeckZhCn, en: legacyDeckEn });
|
||||
const runningBots = Object.values(activeBots).filter((b) => b.docker_status === 'RUNNING');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative animate-in fade-in zoom-in duration-700">
|
||||
<div className="absolute inset-0">
|
||||
<WorkingDeck />
|
||||
</div>
|
||||
|
||||
<div className="absolute top-8 left-8 p-6 bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl pointer-events-none shadow-2xl">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-blue-600/20 rounded-xl flex items-center justify-center border border-blue-500/30">
|
||||
<Radio className="text-blue-400 animate-pulse" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-black tracking-tighter text-white">
|
||||
{t.operationsDeck}
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-[0.2em]">{t.sandbox}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between gap-8 text-[10px] font-bold">
|
||||
<span className="text-slate-500">{t.activeNodes}</span>
|
||||
<span className="text-blue-400">
|
||||
{runningBots.length} / {Object.keys(activeBots).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-white/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-1000"
|
||||
style={{ width: `${(runningBots.length / (Object.keys(activeBots).length || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-8 right-8 space-y-4 pointer-events-none">
|
||||
{runningBots.slice(0, 3).map((bot) => (
|
||||
<div key={bot.id} className="p-4 bg-black/40 backdrop-blur-xl border border-white/10 rounded-xl flex items-center gap-4 min-w-[240px] animate-in slide-in-from-right-4">
|
||||
<div className="p-2 bg-green-500/10 rounded-lg">
|
||||
<Activity size={16} className="text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-bold text-white">{bot.name}</span>
|
||||
<span className="text-[8px] font-mono text-green-500 uppercase">{bot.current_state || t.idle}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-slate-500 truncate max-w-[150px]">{bot.last_action || t.standby}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-[60%] p-4 bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-slate-400 shrink-0">
|
||||
<Terminal size={14} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{t.globalEvents}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden h-5">
|
||||
<div className="animate-marquee whitespace-nowrap text-xs font-mono text-blue-400/80">
|
||||
{runningBots.map((b) => `[${b.name}] ${b.last_action || t.listening}`).join(' • ') ||
|
||||
t.standby}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { useRef } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { Html } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface VoxelBotProps {
|
||||
name: string;
|
||||
currentState?: string;
|
||||
lastAction?: string;
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
export function VoxelBot({ name, currentState, lastAction, position }: VoxelBotProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
if (currentState === 'THINKING') {
|
||||
meshRef.current.position.y = Math.sin(state.clock.elapsedTime * 10) * 0.1;
|
||||
} else if (currentState === 'TOOL_CALL') {
|
||||
meshRef.current.rotation.y += 0.1;
|
||||
} else {
|
||||
meshRef.current.position.y = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const stateColor =
|
||||
currentState === 'THINKING'
|
||||
? '#3b82f6'
|
||||
: currentState === 'TOOL_CALL'
|
||||
? '#f59e0b'
|
||||
: currentState === 'SUCCESS'
|
||||
? '#10b981'
|
||||
: '#4b5563';
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh ref={meshRef} castShadow receiveShadow>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color={stateColor} />
|
||||
</mesh>
|
||||
|
||||
<Html position={[0, 1.2, 0]} center>
|
||||
<div className="bg-black/80 text-white px-2 py-1 rounded text-xs whitespace-nowrap shadow-lg">
|
||||
<p className="font-bold">{name}</p>
|
||||
{lastAction && <p className="text-gray-400 italic text-[10px]">{lastAction}</p>}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Canvas } from '@react-three/fiber';
|
||||
import { ContactShadows, OrbitControls } from '@react-three/drei';
|
||||
import { useAppStore } from '../../../store/appStore';
|
||||
import { VoxelBot } from './VoxelBot';
|
||||
import { pickLocale } from '../../../i18n';
|
||||
import { legacyDeckZhCn } from '../../../i18n/legacy-deck.zh-cn';
|
||||
import { legacyDeckEn } from '../../../i18n/legacy-deck.en';
|
||||
|
||||
export function WorkingDeck() {
|
||||
const activeBots = useAppStore((state) => state.activeBots);
|
||||
const locale = useAppStore((state) => state.locale);
|
||||
const t = pickLocale(locale, { 'zh-cn': legacyDeckZhCn, en: legacyDeckEn });
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-slate-900 rounded-xl overflow-hidden shadow-2xl relative">
|
||||
<Canvas shadows camera={{ position: [5, 5, 5], fov: 40 }}>
|
||||
<color attach="background" args={['#0f172a']} />
|
||||
|
||||
<ambientLight intensity={0.7} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1.5} castShadow />
|
||||
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]} receiveShadow>
|
||||
<planeGeometry args={[20, 20]} />
|
||||
<meshStandardMaterial color="#1e293b" roughness={0.8} />
|
||||
</mesh>
|
||||
<ContactShadows position={[0, -0.49, 0]} scale={20} blur={2} far={4.5} />
|
||||
|
||||
{Object.values(activeBots).map((bot, index) => {
|
||||
const x = (index % 5) * 2 - 4;
|
||||
const z = Math.floor(index / 5) * 2 - 2;
|
||||
return (
|
||||
<VoxelBot
|
||||
key={bot.id}
|
||||
name={bot.name}
|
||||
currentState={bot.current_state}
|
||||
lastAction={bot.last_action}
|
||||
position={[x, 0, z]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2.2} />
|
||||
</Canvas>
|
||||
<div className="absolute top-4 left-4 bg-black/40 backdrop-blur-md p-3 rounded-lg border border-white/10 text-white">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{t.deckTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400">{t.deckSub}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import type { BotEvent, ChatMessage } from '../../types/bot';
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||
const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g;
|
||||
const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。,!?:;、“”‘’()《》【】—…·\-_./:\\,%+*='"`|<>]/g;
|
||||
const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi;
|
||||
|
||||
function cleanLine(line: string) {
|
||||
return line
|
||||
.replace(OSC_RE, '')
|
||||
.replace(ANSI_RE, '')
|
||||
.replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
|
||||
.replace(/\[(\d+)?K/g, '')
|
||||
.replace(NON_TEXT_RE, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeUserMessageText(input: string) {
|
||||
let text = (input || '').replace(/\r\n/g, '\n').trim();
|
||||
if (!text) return '';
|
||||
|
||||
// Keep attachment list out of editable/visible command text.
|
||||
text = text.replace(ATTACHMENT_BLOCK_RE, '').trim();
|
||||
|
||||
// Strip role prefixes injected by some gateways, e.g. "You: ...".
|
||||
text = text.replace(/(^|\n)\s*(you|user|你)\s*[::]\s*/gi, '$1').trim();
|
||||
text = text.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Collapse duplicate echoes like "xxx You: xxx" / "xxx xxx".
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
const prefixedRepeat = flat.match(/^(.{4,}?)\s+(you|user|你)\s*[::]\s*\1$/iu);
|
||||
if (prefixedRepeat) return prefixedRepeat[1].trim();
|
||||
const exactRepeat = flat.match(/^(.{4,}?)\s+\1$/u);
|
||||
if (exactRepeat) return exactRepeat[1].trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function normalizeAssistantMessageText(input: string) {
|
||||
let text = String(input || '')
|
||||
.replace(OSC_RE, '')
|
||||
.replace(ANSI_RE, '')
|
||||
.replace(CONTROL_RE, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.trim();
|
||||
if (!text) return '';
|
||||
|
||||
// Remove dashboard wrapper if channel accidentally outputs raw marker line.
|
||||
text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim();
|
||||
|
||||
// Reduce excessive blank lines while keeping markdown readability.
|
||||
text = text.replace(/\n{4,}/g, '\n\n\n');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function summarizeProgressText(input: string, isZh: boolean) {
|
||||
const raw = normalizeAssistantMessageText(input);
|
||||
if (!raw) return isZh ? '处理中...' : 'Processing...';
|
||||
const firstLine = raw
|
||||
.split('\n')
|
||||
.map((v) => v.trim())
|
||||
.find((v) => v.length > 0);
|
||||
const line = (firstLine || raw)
|
||||
.replace(/[`*_>#|\[\]\(\)]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!line) return isZh ? '处理中...' : 'Processing...';
|
||||
return line.length > 96 ? `${line.slice(0, 96)}...` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心逻辑:日志解析器仅用于提取“状态事件”(用于显示思考气泡)。
|
||||
* 所有的正式对话气泡(用户指令、AI回复)必须由结构化总线消息驱动。
|
||||
*/
|
||||
export function parseLogToArtifacts(
|
||||
raw: string,
|
||||
ts: number = Date.now(),
|
||||
): { message?: ChatMessage; event?: BotEvent } | null {
|
||||
const line = cleanLine(raw);
|
||||
if (!line || line.length < 3) return null;
|
||||
const lower = line.toLowerCase();
|
||||
|
||||
// 1. 忽略结构化标签、系统日志和心跳干扰
|
||||
if (
|
||||
lower.includes('__dashboard_data') ||
|
||||
lower.includes('litellm') ||
|
||||
lower.includes('heartbeat') ||
|
||||
lower.includes('starting nanobot gateway')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 仅提取思考/工具执行状态
|
||||
if (lower.includes('nanobot is thinking')) {
|
||||
return { event: { state: 'THINKING', text: 'Thinking', ts } };
|
||||
}
|
||||
|
||||
const toolMatch = line.match(/execut(?:e|ing) tool[:\s]+([\w\-./]+)/i);
|
||||
if (toolMatch) {
|
||||
return { event: { state: 'TOOL_CALL', text: `Executing Tool: ${toolMatch[1]}`, ts } };
|
||||
}
|
||||
|
||||
// 3. 错误状态提取
|
||||
if (lower.includes('traceback') || (lower.includes('error') && !lower.includes('no error'))) {
|
||||
return { event: { state: 'ERROR', text: 'Execution Error', ts } };
|
||||
}
|
||||
|
||||
// 绝对不返回 message 对象
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { APP_ENDPOINTS } from '../../config/env';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { pickLocale } from '../../i18n';
|
||||
import { imageFactoryZhCn } from '../../i18n/image-factory.zh-cn';
|
||||
import { imageFactoryEn } from '../../i18n/image-factory.en';
|
||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
|
||||
interface NanobotImage {
|
||||
tag: string;
|
||||
image_id?: string;
|
||||
version: string;
|
||||
status: string;
|
||||
source_dir?: string;
|
||||
}
|
||||
|
||||
interface DockerImage {
|
||||
tag: string;
|
||||
image_id: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
function statusClass(status: string) {
|
||||
if (status === 'READY') return 'badge badge-ok';
|
||||
if (status === 'BUILDING') return 'badge badge-warn';
|
||||
if (status === 'ERROR') return 'badge badge-err';
|
||||
return 'badge badge-unknown';
|
||||
}
|
||||
|
||||
export function ImageFactoryModule() {
|
||||
const { locale } = useAppStore();
|
||||
const { notify, confirm } = useLucentPrompt();
|
||||
const [registeredImages, setRegisteredImages] = useState<NanobotImage[]>([]);
|
||||
const [dockerImages, setDockerImages] = useState<DockerImage[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isRegisteringTag, setIsRegisteringTag] = useState('');
|
||||
const [isDeletingTag, setIsDeletingTag] = useState('');
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
const t = pickLocale(locale, { 'zh-cn': imageFactoryZhCn, en: imageFactoryEn });
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsRefreshing(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const [dbRes, dockerRes] = await Promise.allSettled([
|
||||
axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`, { timeout: 10000 }),
|
||||
axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`, { timeout: 10000 }),
|
||||
]);
|
||||
|
||||
if (dbRes.status === 'fulfilled') {
|
||||
setRegisteredImages(dbRes.value.data);
|
||||
} else {
|
||||
setRegisteredImages([]);
|
||||
setLoadError((prev) => `${prev}${prev ? ' | ' : ''}images failed`);
|
||||
}
|
||||
|
||||
if (dockerRes.status === 'fulfilled') {
|
||||
setDockerImages(dockerRes.value.data);
|
||||
} else {
|
||||
setDockerImages([]);
|
||||
setLoadError((prev) => `${prev}${prev ? ' | ' : ''}docker-images failed`);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDockerImages = async () => {
|
||||
setIsRefreshing(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const dockerRes = await axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`, { timeout: 10000 });
|
||||
setDockerImages(dockerRes.data);
|
||||
} catch {
|
||||
setDockerImages([]);
|
||||
setLoadError('docker-images failed');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData().catch((err) => {
|
||||
console.error(err);
|
||||
notify(t.fetchFail, { tone: 'error' });
|
||||
});
|
||||
}, [notify, t.fetchFail]);
|
||||
|
||||
const registeredTags = useMemo(() => new Set(registeredImages.map((img) => img.tag)), [registeredImages]);
|
||||
|
||||
const handleRegister = async (tag: string) => {
|
||||
setIsRegisteringTag(tag);
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, {
|
||||
tag,
|
||||
source_dir: 'manual',
|
||||
});
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || t.registerFail;
|
||||
notify(msg, { tone: 'error' });
|
||||
} finally {
|
||||
setIsRegisteringTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRegistered = async (tag: string) => {
|
||||
const ok = await confirm({
|
||||
title: t.deleteRegistry,
|
||||
message: t.deleteConfirm(tag),
|
||||
tone: 'warning',
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsDeletingTag(tag);
|
||||
try {
|
||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/images/${encodeURIComponent(tag)}`);
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || t.deleteFail;
|
||||
notify(msg, { tone: 'error' });
|
||||
} finally {
|
||||
setIsDeletingTag('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-2 image-grid">
|
||||
<section className="panel stack">
|
||||
<div className="row-between">
|
||||
<div>
|
||||
<h2>{t.registeredTitle}</h2>
|
||||
<p className="panel-desc">{t.registeredDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
{loadError && (
|
||||
<div className="card" style={{ color: 'var(--err)', fontSize: 12 }}>
|
||||
{t.partialLoadFail(loadError)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="factory-kpi-grid">
|
||||
<div className="kpi-card">
|
||||
<div className="kpi-label">{t.registeredCount}</div>
|
||||
<div className="kpi-value">{registeredImages.length}</div>
|
||||
</div>
|
||||
<div className="kpi-card">
|
||||
<div className="kpi-label">READY</div>
|
||||
<div className="kpi-value">{registeredImages.filter((x) => x.status === 'READY').length}</div>
|
||||
</div>
|
||||
<div className="kpi-card">
|
||||
<div className="kpi-label">{t.abnormal}</div>
|
||||
<div className="kpi-value">{registeredImages.filter((x) => x.status !== 'READY').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="list-scroll" style={{ maxHeight: '62vh' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>{t.action}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{registeredImages.map((img) => (
|
||||
<tr key={img.tag}>
|
||||
<td>
|
||||
<div className="mono">{img.tag}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 11 }}>source: {img.source_dir || 'manual'}</div>
|
||||
{img.image_id && <div className="mono" style={{ color: 'var(--muted)', fontSize: 11 }}>id: {img.image_id}</div>}
|
||||
</td>
|
||||
<td>{img.version}</td>
|
||||
<td><span className={statusClass(img.status)}>{img.status}</span></td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-sm icon-btn"
|
||||
disabled={isDeletingTag === img.tag}
|
||||
onClick={() => void handleDeleteRegistered(img.tag)}
|
||||
title={isDeletingTag === img.tag ? t.deleting : t.deleteRegistry}
|
||||
aria-label={isDeletingTag === img.tag ? t.deleting : t.deleteRegistry}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{registeredImages.length === 0 && <p style={{ color: 'var(--muted)' }}>{t.noRegistered}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel stack">
|
||||
<div className="row-between">
|
||||
<div>
|
||||
<h2>{t.dockerTitle}</h2>
|
||||
<p className="panel-desc">{t.dockerDesc}</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary icon-btn"
|
||||
onClick={() => void refreshDockerImages()}
|
||||
title={isRefreshing ? t.refreshing : t.refresh}
|
||||
aria-label={isRefreshing ? t.refreshing : t.refresh}
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{t.dockerTip}
|
||||
</div>
|
||||
|
||||
<div className="list-scroll" style={{ maxHeight: '64vh' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Image ID</th>
|
||||
<th>{t.action}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dockerImages.map((img) => {
|
||||
const already = registeredTags.has(img.tag);
|
||||
return (
|
||||
<tr key={img.tag}>
|
||||
<td>
|
||||
<div className="mono">{img.tag}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 11 }}>version: {img.version}</div>
|
||||
</td>
|
||||
<td className="mono" style={{ fontSize: 11 }}>{img.image_id.slice(0, 22)}...</td>
|
||||
<td>
|
||||
<button
|
||||
className={`btn ${already ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => void handleRegister(img.tag)}
|
||||
disabled={isRegisteringTag === img.tag}
|
||||
>
|
||||
{already ? t.update : t.register}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{dockerImages.length === 0 && <p style={{ color: 'var(--muted)' }}>{t.noDocker}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { Power, PowerOff, Terminal, ShieldCheck, Plus, Bot, Cpu, Layers, RefreshCw } from 'lucide-react';
|
||||
import { CreateBotModal } from './components/CreateBotModal';
|
||||
import { KernelManagerModal } from './components/KernelManagerModal';
|
||||
import { APP_ENDPOINTS } from '../../config/env';
|
||||
import { pickLocale } from '../../i18n';
|
||||
import { managementZhCn } from '../../i18n/management.zh-cn';
|
||||
import { managementEn } from '../../i18n/management.en';
|
||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
|
||||
export function ManagementModule() {
|
||||
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
|
||||
const { notify } = useLucentPrompt();
|
||||
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn });
|
||||
const [selectedBotId, setSelectedBotId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isKernelModalOpen, setIsKernelModalOpen] = useState(false);
|
||||
const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
|
||||
|
||||
const fetchBots = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||
setBots(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBot = async (botId: string, status: string) => {
|
||||
setOperatingBotId(botId);
|
||||
try {
|
||||
if (status === 'RUNNING') {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/stop`);
|
||||
updateBotStatus(botId, 'STOPPED');
|
||||
} else {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/start`);
|
||||
updateBotStatus(botId, 'RUNNING');
|
||||
}
|
||||
// 关键:操作完成后立即刷新列表
|
||||
await fetchBots();
|
||||
} catch {
|
||||
notify(t.opFail, { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedBot = selectedBotId ? activeBots[selectedBotId] : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="w-1/3 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center px-2">
|
||||
<h2 className="text-sm font-black text-slate-500 uppercase tracking-widest">{t.botInstances}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setIsKernelModalOpen(true)} className="p-2 bg-slate-800 hover:bg-slate-700 rounded-lg transition-all text-blue-400 border border-blue-500/20 shadow-lg shadow-blue-500/10">
|
||||
<ShieldCheck size={16} />
|
||||
</button>
|
||||
<button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 px-3 py-2 rounded-lg text-white font-bold text-xs transition-all active:scale-95 shadow-lg shadow-blue-600/30">
|
||||
<Plus size={14} /> {t.newBot}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-thin">
|
||||
{Object.values(activeBots).map((bot) => (
|
||||
<div
|
||||
key={bot.id}
|
||||
onClick={() => setSelectedBotId(bot.id)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
selectedBotId === bot.id ? 'bg-blue-600/10 border-blue-500/50' : 'bg-slate-900/50 border-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${bot.docker_status === 'RUNNING' ? 'bg-green-500/10 text-green-500' : 'bg-slate-800 text-slate-500'}`}>
|
||||
<Bot size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold">{bot.name}</h3>
|
||||
<p className="text-[10px] font-mono text-slate-500">{bot.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={operatingBotId === bot.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBot(bot.id, bot.docker_status);
|
||||
}}
|
||||
className={`p-2 rounded-lg transition-all ${operatingBotId === bot.id ? 'opacity-50 cursor-not-allowed' : ''} ${bot.docker_status === 'RUNNING' ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'}`}
|
||||
>
|
||||
{operatingBotId === bot.id ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : bot.docker_status === 'RUNNING' ? (
|
||||
<PowerOff size={14} />
|
||||
) : (
|
||||
<Power size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-6">
|
||||
{selectedBot ? (
|
||||
<>
|
||||
<div className="bg-slate-900/50 border border-white/5 rounded-2xl p-6">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black">{selectedBot.name}</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">{t.detailsTitle}</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-[10px] font-black uppercase ${selectedBot.docker_status === 'RUNNING' ? 'bg-green-500/10 text-green-500 border border-green-500/20' : 'bg-slate-800 text-slate-500 border border-white/5'}`}>
|
||||
{selectedBot.docker_status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-black/20 p-4 rounded-xl border border-white/5">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-2">
|
||||
<Layers size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">{t.kernel}</span>
|
||||
</div>
|
||||
<p className="text-sm font-mono text-blue-400 truncate">{selectedBot.image_tag || 'nanobot-base:v0.1.4'}</p>
|
||||
</div>
|
||||
<div className="bg-black/20 p-4 rounded-xl border border-white/5">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-2">
|
||||
<Cpu size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">{t.model}</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">{selectedBot.llm_model || '-'}</p>
|
||||
</div>
|
||||
<div className="bg-black/20 p-4 rounded-xl border border-white/5">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-2">
|
||||
<Terminal size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">{t.status}</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">{selectedBot.current_state || t.idle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-black rounded-2xl border border-white/5 flex flex-col overflow-hidden shadow-2xl">
|
||||
<div className="px-6 py-3 bg-white/[0.02] border-b border-white/5 flex items-center gap-3">
|
||||
<Terminal size={14} className="text-blue-500" />
|
||||
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{t.consoleOutput}</span>
|
||||
</div>
|
||||
<div className="flex-1 p-6 font-mono text-[11px] overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-white/10">
|
||||
{selectedBot.logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-4 border-b border-white/[0.01] pb-1">
|
||||
<span className="text-slate-700 shrink-0">{(i + 1).toString().padStart(3, '0')}</span>
|
||||
<span className={log.includes('Executing') ? 'text-amber-400' : 'text-slate-400'}>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center border border-dashed border-white/5 rounded-2xl text-slate-600">
|
||||
<Bot size={48} className="mb-4 opacity-20" />
|
||||
<p className="text-sm italic tracking-wide">{t.selectHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateBotModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} onSuccess={fetchBots} />
|
||||
<KernelManagerModal isOpen={isKernelModalOpen} onClose={() => setIsKernelModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Bot, Cpu, Key, FileText, Layers } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import { useAppStore } from '../../../store/appStore';
|
||||
import { pickLocale } from '../../../i18n';
|
||||
import { managementZhCn } from '../../../i18n/management.zh-cn';
|
||||
import { managementEn } from '../../../i18n/management.en';
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
|
||||
interface CreateBotModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface NanobotImage {
|
||||
tag: string;
|
||||
status: string;
|
||||
source_dir?: string;
|
||||
}
|
||||
|
||||
export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalProps) {
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const { notify } = useLucentPrompt();
|
||||
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).create;
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'gpt-4o',
|
||||
api_key: '',
|
||||
system_prompt: t.systemPrompt,
|
||||
image_tag: 'nanobot-base:v0.1.4',
|
||||
});
|
||||
|
||||
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`)
|
||||
.then((res) => setAvailableImages(res.data.filter((img) => img.status === 'READY' || img.status === 'UNKNOWN')))
|
||||
.catch((err) => console.error('Failed to fetch images', err));
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, formData);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch {
|
||||
notify(t.createFail, { tone: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-800 bg-slate-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-600/20 rounded-lg text-white">
|
||||
<Bot className="text-blue-400" size={24} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">{t.title}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-slate-700 rounded-full transition-colors text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto max-h-[80vh] scrollbar-thin scrollbar-thumb-white/10 pr-2">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">{t.idLabel}</label>
|
||||
<input
|
||||
required
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
||||
placeholder={t.idPlaceholder}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">{t.nameLabel}</label>
|
||||
<input
|
||||
required
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
||||
placeholder={t.namePlaceholder}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
||||
<Layers size={14} className="text-blue-500" /> {t.imageLabel}
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none text-white"
|
||||
onChange={(e) => setFormData({ ...formData, image_tag: e.target.value })}
|
||||
value={formData.image_tag}
|
||||
>
|
||||
{availableImages.map((img) => (
|
||||
<option key={img.tag} value={img.tag}>
|
||||
{img.tag} ({img.source_dir ? `${t.source}: ${img.source_dir}` : t.pypi})
|
||||
</option>
|
||||
))}
|
||||
{availableImages.length === 0 && <option disabled>{t.noImage}</option>}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-600 italic">{t.imageHint}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
||||
<Cpu size={14} /> {t.providerLabel}
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none text-white"
|
||||
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="kimi">Kimi (Moonshot)</option>
|
||||
<option value="minimax">MiniMax</option>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 text-white">{t.modelLabel}</label>
|
||||
<input
|
||||
required
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
||||
placeholder={t.modelPlaceholder}
|
||||
defaultValue="gpt-4o"
|
||||
onChange={(e) => setFormData({ ...formData, llm_model: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
||||
<Key size={14} /> API Key
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
||||
placeholder="sk-..."
|
||||
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
||||
<FileText size={14} /> {t.soulLabel}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm h-32 focus:border-blue-500 outline-none transition-all resize-none text-white"
|
||||
defaultValue={formData.system_prompt}
|
||||
onChange={(e) => setFormData({ ...formData, system_prompt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-slate-800 hover:bg-slate-700 py-3 rounded-xl font-bold transition-all text-white"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-900/40 text-white"
|
||||
>
|
||||
<Save size={18} /> {t.submit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { X, Cpu, CheckCircle, AlertCircle, Loader2, HardDrive, Trash2 } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import { useAppStore } from '../../../store/appStore';
|
||||
import { pickLocale } from '../../../i18n';
|
||||
import { managementZhCn } from '../../../i18n/management.zh-cn';
|
||||
import { managementEn } from '../../../i18n/management.en';
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
|
||||
interface KernelManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface NanobotImage {
|
||||
tag: string;
|
||||
status: string;
|
||||
source_dir?: string;
|
||||
}
|
||||
|
||||
export function KernelManagerModal({ isOpen, onClose }: KernelManagerModalProps) {
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const { notify, confirm } = useLucentPrompt();
|
||||
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).kernelModal;
|
||||
const [images, setImages] = useState<NanobotImage[]>([]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const imgRes = await axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`);
|
||||
setImages(imgRes.data);
|
||||
} catch (err) {
|
||||
console.error(t.fetchFailed, err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleRemoveImage = async (tag: string) => {
|
||||
const ok = await confirm({
|
||||
title: t.removeRecord,
|
||||
message: t.removeConfirm(tag),
|
||||
tone: 'warning',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/images/${encodeURIComponent(tag)}`);
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
notify(error.response?.data?.detail || t.removeFail, { tone: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-3xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-800 bg-slate-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="text-blue-400" size={24} />
|
||||
<h2 className="text-xl font-bold text-white">{t.title}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-slate-700 rounded-full transition-colors text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">{t.readySection}</h3>
|
||||
<div className="space-y-2 h-[300px] overflow-y-auto pr-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.tag} className="p-3 rounded-lg bg-white/[0.03] border border-white/5 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white">{img.tag}</p>
|
||||
<p className="text-[10px] text-slate-500">{t.source}: {img.source_dir || t.pypi}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{img.status === 'READY' && <CheckCircle size={14} className="text-green-500" />}
|
||||
{img.status === 'BUILDING' && <Loader2 size={14} className="text-blue-500 animate-spin" />}
|
||||
{img.status === 'ERROR' && <AlertCircle size={14} className="text-red-500" />}
|
||||
<span className={`text-[9px] font-bold ${img.status === 'READY' ? 'text-green-500' : 'text-slate-400'}`}>
|
||||
{img.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(img.tag)}
|
||||
className="ml-2 p-1.5 hover:bg-red-500/20 text-slate-500 hover:text-red-500 rounded transition-colors"
|
||||
title={t.removeRecord}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-800/30 border-t border-slate-800 flex items-center gap-2 text-[10px] text-slate-500">
|
||||
<HardDrive size={12} />
|
||||
<span>{t.footerHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,852 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Eye, EyeOff, Plus, Settings2, Trash2 } from 'lucide-react';
|
||||
import { APP_ENDPOINTS } from '../../config/env';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { channelsZhCn } from '../../i18n/channels.zh-cn';
|
||||
import { channelsEn } from '../../i18n/channels.en';
|
||||
import { pickLocale } from '../../i18n';
|
||||
import { wizardZhCn } from '../../i18n/wizard.zh-cn';
|
||||
import { wizardEn } from '../../i18n/wizard.en';
|
||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
|
||||
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
|
||||
|
||||
const FALLBACK_SOUL_MD = '# Soul\n\n你是专业的企业数字员工,表达清晰、可执行。';
|
||||
const FALLBACK_AGENTS_MD = '# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown(.md)格式。';
|
||||
const FALLBACK_USER_MD = '# User\n\n- 语言: 中文\n- 风格: 专业\n- 偏好: 简明且有步骤';
|
||||
const FALLBACK_TOOLS_MD = '# Tools\n\n- 谨慎使用 shell\n- 修改文件后复核\n- 失败时说明原因并重试策略';
|
||||
const FALLBACK_IDENTITY_MD = '# Identity\n\n- 角色: 企业数字员工\n- 领域: 运维与任务执行';
|
||||
|
||||
interface WizardChannelConfig {
|
||||
channel_type: ChannelType;
|
||||
is_active: boolean;
|
||||
external_app_id: string;
|
||||
app_secret: string;
|
||||
internal_port: number;
|
||||
extra_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface NanobotImage {
|
||||
tag: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface SystemDefaultsResponse {
|
||||
templates?: {
|
||||
soul_md?: string;
|
||||
agents_md?: string;
|
||||
user_md?: string;
|
||||
tools_md?: string;
|
||||
identity_md?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const providerPresets: Record<string, { model: string; note: { 'zh-cn': string; en: string }; apiBase?: string }> = {
|
||||
openrouter: {
|
||||
model: 'openai/gpt-4o-mini',
|
||||
note: {
|
||||
'zh-cn': 'OpenRouter 网关,模型名示例 openai/gpt-4o-mini。',
|
||||
en: 'OpenRouter gateway, model example: openai/gpt-4o-mini.',
|
||||
},
|
||||
apiBase: 'https://openrouter.ai/api/v1',
|
||||
},
|
||||
dashscope: {
|
||||
model: 'qwen-plus',
|
||||
note: {
|
||||
'zh-cn': '阿里云 DashScope(千问),模型示例 qwen-plus / qwen-max。',
|
||||
en: 'Alibaba DashScope (Qwen), model example: qwen-plus / qwen-max.',
|
||||
},
|
||||
apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
openai: {
|
||||
model: 'gpt-4o-mini',
|
||||
note: {
|
||||
'zh-cn': 'OpenAI 原生模型。',
|
||||
en: 'OpenAI native models.',
|
||||
},
|
||||
},
|
||||
deepseek: {
|
||||
model: 'deepseek-chat',
|
||||
note: {
|
||||
'zh-cn': 'DeepSeek 原生模型。',
|
||||
en: 'DeepSeek native models.',
|
||||
},
|
||||
},
|
||||
kimi: {
|
||||
model: 'moonshot-v1-8k',
|
||||
note: {
|
||||
'zh-cn': 'Kimi(Moonshot)接口,模型示例 moonshot-v1-8k。',
|
||||
en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k.',
|
||||
},
|
||||
apiBase: 'https://api.moonshot.cn/v1',
|
||||
},
|
||||
minimax: {
|
||||
model: 'MiniMax-Text-01',
|
||||
note: {
|
||||
'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01。',
|
||||
en: 'MiniMax endpoint, model example: MiniMax-Text-01.',
|
||||
},
|
||||
apiBase: 'https://api.minimax.chat/v1',
|
||||
},
|
||||
};
|
||||
|
||||
const initialForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
llm_provider: 'dashscope',
|
||||
llm_model: providerPresets.dashscope.model,
|
||||
api_key: '',
|
||||
api_base: providerPresets.dashscope.apiBase ?? '',
|
||||
image_tag: '',
|
||||
|
||||
temperature: 0.2,
|
||||
top_p: 1.0,
|
||||
max_tokens: 8192,
|
||||
|
||||
soul_md: FALLBACK_SOUL_MD,
|
||||
agents_md: FALLBACK_AGENTS_MD,
|
||||
user_md: FALLBACK_USER_MD,
|
||||
tools_md: FALLBACK_TOOLS_MD,
|
||||
identity_md: FALLBACK_IDENTITY_MD,
|
||||
env_params: {} as Record<string, string>,
|
||||
send_progress: false,
|
||||
send_tool_hints: false,
|
||||
channels: [] as WizardChannelConfig[],
|
||||
};
|
||||
|
||||
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack'];
|
||||
|
||||
interface BotWizardModuleProps {
|
||||
onCreated?: () => void;
|
||||
onGoDashboard?: () => void;
|
||||
}
|
||||
|
||||
export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModuleProps) {
|
||||
const { locale } = useAppStore();
|
||||
const { notify } = useLucentPrompt();
|
||||
const [step, setStep] = useState(1);
|
||||
const [images, setImages] = useState<NanobotImage[]>([]);
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [autoStart, setAutoStart] = useState(true);
|
||||
const [isTestingProvider, setIsTestingProvider] = useState(false);
|
||||
const [testResult, setTestResult] = useState('');
|
||||
const [agentTab, setAgentTab] = useState<AgentTab>('AGENTS');
|
||||
const [showChannelModal, setShowChannelModal] = useState(false);
|
||||
const [showToolsConfigModal, setShowToolsConfigModal] = useState(false);
|
||||
const [envDraftKey, setEnvDraftKey] = useState('');
|
||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(FALLBACK_AGENTS_MD);
|
||||
|
||||
const readyImages = useMemo(() => images.filter((img) => img.status === 'READY'), [images]);
|
||||
const isZh = locale === 'zh';
|
||||
const ui = pickLocale(locale, { 'zh-cn': wizardZhCn, en: wizardEn });
|
||||
const lc = isZh ? channelsZhCn : channelsEn;
|
||||
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
|
||||
const activeChannelTypes = useMemo(() => new Set(form.channels.map((c) => c.channel_type)), [form.channels]);
|
||||
const addableChannelTypes = useMemo(
|
||||
() => optionalChannelTypes.filter((t) => !activeChannelTypes.has(t)),
|
||||
[activeChannelTypes],
|
||||
);
|
||||
const envEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(form.env_params || {})
|
||||
.filter(([k]) => String(k || '').trim().length > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
[form.env_params],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSystemDefaults = async () => {
|
||||
try {
|
||||
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
|
||||
const tpl = res.data?.templates || {};
|
||||
const agentsTemplate = String(tpl.agents_md || '').trim() || FALLBACK_AGENTS_MD;
|
||||
setDefaultAgentsTemplate(agentsTemplate);
|
||||
setForm((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
soul_md: String(tpl.soul_md || '').trim() || prev.soul_md,
|
||||
agents_md: agentsTemplate,
|
||||
user_md: String(tpl.user_md || '').trim() || prev.user_md,
|
||||
tools_md: String(tpl.tools_md || '').trim() || prev.tools_md,
|
||||
identity_md: String(tpl.identity_md || '').trim() || prev.identity_md,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// keep fallback templates
|
||||
}
|
||||
};
|
||||
void loadSystemDefaults();
|
||||
}, []);
|
||||
const configuredChannelsLabel = useMemo(
|
||||
() => (form.channels.length > 0 ? form.channels.map((c) => c.channel_type).join(', ') : '-'),
|
||||
[form.channels],
|
||||
);
|
||||
|
||||
const loadImages = async () => {
|
||||
setIsLoadingImages(true);
|
||||
try {
|
||||
const res = await axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`);
|
||||
setImages(res.data);
|
||||
const ready = res.data.filter((img) => img.status === 'READY');
|
||||
if (!form.image_tag && ready.length > 0) {
|
||||
setForm((prev) => ({ ...prev, image_tag: ready[0].tag }));
|
||||
}
|
||||
return ready;
|
||||
} finally {
|
||||
setIsLoadingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const next = async () => {
|
||||
if (step === 1) {
|
||||
const ready = await loadImages();
|
||||
if (ready.length === 0) {
|
||||
notify(ui.noReadyImage, { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
if (!form.id || !form.name || !form.api_key || !form.image_tag || !form.llm_model) {
|
||||
notify(ui.requiredBase, { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (step < 4) {
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const testProvider = async () => {
|
||||
if (!form.llm_provider || !form.api_key || !form.llm_model) {
|
||||
notify(ui.providerRequired, { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTestingProvider(true);
|
||||
setTestResult('');
|
||||
try {
|
||||
const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, {
|
||||
provider: form.llm_provider,
|
||||
model: form.llm_model,
|
||||
api_key: form.api_key,
|
||||
api_base: form.api_base || undefined,
|
||||
});
|
||||
|
||||
if (res.data?.ok) {
|
||||
const preview = (res.data.models_preview || []).slice(0, 3).join(', ');
|
||||
setTestResult(ui.connOk(preview));
|
||||
} else {
|
||||
setTestResult(ui.connFailed(res.data?.detail || 'unknown error'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || error?.message || 'request failed';
|
||||
setTestResult(ui.connFailed(msg));
|
||||
} finally {
|
||||
setIsTestingProvider(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createBot = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
llm_provider: form.llm_provider,
|
||||
llm_model: form.llm_model,
|
||||
api_key: form.api_key,
|
||||
api_base: form.api_base || undefined,
|
||||
image_tag: form.image_tag,
|
||||
system_prompt: form.soul_md,
|
||||
temperature: clampTemperature(Number(form.temperature)),
|
||||
top_p: Number(form.top_p),
|
||||
max_tokens: Number(form.max_tokens),
|
||||
soul_md: form.soul_md,
|
||||
agents_md: form.agents_md,
|
||||
user_md: form.user_md,
|
||||
tools_md: form.tools_md,
|
||||
identity_md: form.identity_md,
|
||||
send_progress: Boolean(form.send_progress),
|
||||
send_tool_hints: Boolean(form.send_tool_hints),
|
||||
channels: form.channels.map((c) => ({
|
||||
channel_type: c.channel_type,
|
||||
is_active: c.is_active,
|
||||
external_app_id: c.external_app_id,
|
||||
app_secret: c.app_secret,
|
||||
internal_port: c.internal_port,
|
||||
extra_config: sanitizeChannelExtra(c.channel_type, c.extra_config),
|
||||
})),
|
||||
env_params: form.env_params,
|
||||
});
|
||||
|
||||
if (autoStart) {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`);
|
||||
}
|
||||
onCreated?.();
|
||||
onGoDashboard?.();
|
||||
setForm(initialForm);
|
||||
setStep(1);
|
||||
setTestResult('');
|
||||
notify(ui.created, { tone: 'success' });
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || ui.createFailed;
|
||||
notify(msg, { tone: 'error' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onProviderChange = (provider: string) => {
|
||||
const preset = providerPresets[provider] ?? { model: '' };
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
llm_provider: provider,
|
||||
llm_model: preset.model || p.llm_model,
|
||||
api_base: preset.apiBase ?? '',
|
||||
}));
|
||||
setTestResult('');
|
||||
};
|
||||
|
||||
const tabMap: Record<AgentTab, keyof typeof form> = {
|
||||
AGENTS: 'agents_md',
|
||||
SOUL: 'soul_md',
|
||||
USER: 'user_md',
|
||||
TOOLS: 'tools_md',
|
||||
IDENTITY: 'identity_md',
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
if (!addableChannelTypes.includes(newChannelType)) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
channels: [
|
||||
...prev.channels,
|
||||
{
|
||||
channel_type: newChannelType,
|
||||
is_active: true,
|
||||
external_app_id: '',
|
||||
app_secret: '',
|
||||
internal_port: 8080,
|
||||
extra_config: {},
|
||||
},
|
||||
],
|
||||
}));
|
||||
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
|
||||
if (rest.length > 0) setNewChannelType(rest[0]);
|
||||
};
|
||||
|
||||
const upsertEnvParam = (key: string, value: string) => {
|
||||
const normalized = String(key || '').trim().toUpperCase();
|
||||
if (!normalized) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
env_params: {
|
||||
...(prev.env_params || {}),
|
||||
[normalized]: String(value || ''),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const removeEnvParam = (key: string) => {
|
||||
const normalized = String(key || '').trim().toUpperCase();
|
||||
if (!normalized) return;
|
||||
setForm((prev) => {
|
||||
const next = { ...(prev.env_params || {}) };
|
||||
delete next[normalized];
|
||||
return { ...prev, env_params: next };
|
||||
});
|
||||
};
|
||||
|
||||
const updateChannel = (index: number, patch: Partial<WizardChannelConfig>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
channels: prev.channels.map((c, i) => (i === index ? { ...c, ...patch } : c)),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeChannel = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
channels: prev.channels.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const clampMaxTokens = (value: number) => {
|
||||
if (Number.isNaN(value)) return 8192;
|
||||
return Math.min(32768, Math.max(256, Math.round(value)));
|
||||
};
|
||||
const clampTemperature = (value: number) => {
|
||||
if (Number.isNaN(value)) return 0.2;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
};
|
||||
|
||||
const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => {
|
||||
setForm((prev) => {
|
||||
if (key === 'sendProgress') return { ...prev, send_progress: value };
|
||||
return { ...prev, send_tool_hints: value };
|
||||
});
|
||||
};
|
||||
const sanitizeChannelExtra = (_channelType: string, extra: Record<string, unknown>) => {
|
||||
const next = { ...(extra || {}) };
|
||||
delete next.sendProgress;
|
||||
delete next.sendToolHints;
|
||||
return next;
|
||||
};
|
||||
|
||||
const renderChannelFields = (channel: WizardChannelConfig, idx: number) => {
|
||||
if (channel.channel_type === 'telegram') {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={lc.telegramToken}
|
||||
value={channel.app_secret}
|
||||
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={lc.proxy}
|
||||
value={String((channel.extra_config || {}).proxy || '')}
|
||||
onChange={(e) =>
|
||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })
|
||||
}
|
||||
/>
|
||||
<label className="field-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean((channel.extra_config || {}).replyToMessage)}
|
||||
onChange={(e) =>
|
||||
updateChannel(idx, {
|
||||
extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked },
|
||||
})
|
||||
}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{lc.replyToMessage}
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.channel_type === 'feishu') {
|
||||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder={lc.encryptKey}
|
||||
value={String((channel.extra_config || {}).encryptKey || '')}
|
||||
onChange={(e) =>
|
||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={lc.verificationToken}
|
||||
value={String((channel.extra_config || {}).verificationToken || '')}
|
||||
onChange={(e) =>
|
||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.channel_type === 'dingtalk') {
|
||||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.clientId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
||||
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.channel_type === 'slack') {
|
||||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.botToken} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
||||
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.channel_type === 'qq') {
|
||||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel stack wizard-shell" style={{ height: '100%' }}>
|
||||
<div className="wizard-head">
|
||||
<h2>{ui.title}</h2>
|
||||
<p className="panel-desc">{ui.sub}</p>
|
||||
</div>
|
||||
|
||||
<div className="wizard-steps wizard-steps-4 wizard-steps-enhanced">
|
||||
<div className={`wizard-step ${step === 1 ? 'active' : ''}`}>{ui.s1}</div>
|
||||
<div className={`wizard-step ${step === 2 ? 'active' : ''}`}>{ui.s2}</div>
|
||||
<div className={`wizard-step ${step === 3 ? 'active' : ''}`}>{ui.s3}</div>
|
||||
<div className={`wizard-step ${step === 4 ? 'active' : ''}`}>{ui.s4}</div>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="stack">
|
||||
<button className="btn btn-secondary" onClick={() => void loadImages()}>{isLoadingImages ? ui.loading : ui.loadImages}</button>
|
||||
<div className="list-scroll" style={{ maxHeight: '52vh' }}>
|
||||
{readyImages.map((img) => (
|
||||
<label key={img.tag} className="card selectable" style={{ display: 'block', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={form.image_tag === img.tag}
|
||||
onChange={() => setForm((prev) => ({ ...prev, image_tag: img.tag }))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="mono">{img.tag}</span>
|
||||
<span style={{ marginLeft: 10 }} className="badge badge-ok">READY</span>
|
||||
</label>
|
||||
))}
|
||||
{readyImages.length === 0 && <div style={{ color: 'var(--muted)' }}>{ui.noReady}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="grid-2 wizard-step2-grid wizard-step2-grid-3" style={{ gridTemplateColumns: '1fr 1fr 1fr' }}>
|
||||
<div className="stack card wizard-step2-card">
|
||||
<div className="section-mini-title">{ui.baseInfo}</div>
|
||||
<input className="input" placeholder={ui.botIdPlaceholder} value={form.id} onChange={(e) => setForm((p) => ({ ...p, id: e.target.value }))} />
|
||||
<input className="input" placeholder={ui.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
||||
|
||||
<div className="section-mini-title">{ui.modelAccess}</div>
|
||||
<select className="select" value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
|
||||
<option value="openrouter">openrouter</option>
|
||||
<option value="dashscope">dashscope (aliyun qwen)</option>
|
||||
<option value="openai">openai</option>
|
||||
<option value="deepseek">deepseek</option>
|
||||
<option value="kimi">kimi (moonshot)</option>
|
||||
<option value="minimax">minimax</option>
|
||||
</select>
|
||||
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
|
||||
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
|
||||
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
|
||||
|
||||
<div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{providerPresets[form.llm_provider]?.note[noteLocale]}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => void testProvider()} disabled={isTestingProvider}>
|
||||
{isTestingProvider ? ui.testing : ui.test}
|
||||
</button>
|
||||
{testResult && <div className="card wizard-note-card">{testResult}</div>}
|
||||
</div>
|
||||
|
||||
<div className="stack card wizard-step2-card">
|
||||
<div className="section-mini-title">{ui.modelParams}</div>
|
||||
<div className="slider-row">
|
||||
<label className="field-label">Temperature: {form.temperature.toFixed(2)}</label>
|
||||
<input type="range" min="0" max="1" step="0.01" value={form.temperature} onChange={(e) => setForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
|
||||
</div>
|
||||
<div className="slider-row">
|
||||
<label className="field-label">Top P: {form.top_p.toFixed(2)}</label>
|
||||
<input type="range" min="0" max="1" step="0.01" value={form.top_p} onChange={(e) => setForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
|
||||
</div>
|
||||
<div className="slider-row token-input-row">
|
||||
<label className="field-label" htmlFor="wizard-max-tokens">Max Tokens</label>
|
||||
<input
|
||||
id="wizard-max-tokens"
|
||||
className="input token-number-input"
|
||||
type="number"
|
||||
step="1"
|
||||
min="256"
|
||||
max="32768"
|
||||
value={form.max_tokens}
|
||||
onChange={(e) => setForm((p) => ({ ...p, max_tokens: clampMaxTokens(Number(e.target.value)) }))}
|
||||
onBlur={(e) => setForm((p) => ({ ...p, max_tokens: clampMaxTokens(Number(e.target.value)) }))}
|
||||
/>
|
||||
<div className="field-label">{ui.tokenRange}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="stack card wizard-step2-card">
|
||||
<div className="section-mini-title">{lc.wizardSectionTitle}</div>
|
||||
<div className="card wizard-note-card wizard-channel-summary">
|
||||
<div className="field-label">{lc.wizardSectionDesc}</div>
|
||||
<div className="mono">
|
||||
{configuredChannelsLabel}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowChannelModal(true)} title={lc.openManager} aria-label={lc.openManager}>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="section-mini-title" style={{ marginTop: 6 }}>{ui.toolsConfig}</div>
|
||||
<div className="card wizard-note-card wizard-channel-summary">
|
||||
<div className="field-label">{ui.toolsDesc}</div>
|
||||
<div className="mono">
|
||||
{envEntries.length > 0 ? envEntries.map(([k]) => k).join(', ') : ui.noEnvParams}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setShowToolsConfigModal(true)}
|
||||
title={ui.openToolsManager}
|
||||
aria-label={ui.openToolsManager}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="wizard-agent-layout">
|
||||
<div className="agent-tabs-vertical">
|
||||
{(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => (
|
||||
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => setAgentTab(tab)}>
|
||||
{tab}.md
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="stack" style={{ minWidth: 0 }}>
|
||||
{agentTab === 'AGENTS' ? (
|
||||
<div className="row-between">
|
||||
<span className="field-label">
|
||||
{isZh
|
||||
? '建议:将“创建新目录并以 Markdown 输出”写入 AGENTS.md'
|
||||
: 'Tip: Put "create output directory + markdown output" in AGENTS.md'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() =>
|
||||
setForm((p) => ({ ...p, agents_md: defaultAgentsTemplate }))
|
||||
}
|
||||
>
|
||||
{isZh ? '插入默认规则' : 'Insert default rule'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
className="textarea md-area"
|
||||
value={String(form[tabMap[agentTab]])}
|
||||
onChange={(e) => setForm((p) => ({ ...p, [tabMap[agentTab]]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="stack">
|
||||
<div className="card summary-grid">
|
||||
<div>{ui.image}: <span className="mono">{form.image_tag}</span></div>
|
||||
<div>Bot ID: <span className="mono">{form.id}</span></div>
|
||||
<div>{ui.name}: {form.name}</div>
|
||||
<div>Provider: {form.llm_provider}</div>
|
||||
<div>{ui.model}: {form.llm_model}</div>
|
||||
<div>Temperature: {form.temperature.toFixed(2)}</div>
|
||||
<div>Top P: {form.top_p.toFixed(2)}</div>
|
||||
<div>Max Tokens: {form.max_tokens}</div>
|
||||
<div>{ui.channels}: {configuredChannelsLabel}</div>
|
||||
<div>{ui.tools}: {envEntries.map(([k]) => k).join(', ') || '-'}</div>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" checked={autoStart} onChange={(e) => setAutoStart(e.target.checked)} style={{ marginRight: 8 }} />
|
||||
{ui.autoStart}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showChannelModal && (
|
||||
<div className="modal-mask" onClick={() => setShowChannelModal(false)}>
|
||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{lc.wizardSectionTitle}</h3>
|
||||
<div className="card">
|
||||
<div className="section-mini-title">{lc.globalDeliveryTitle}</div>
|
||||
<div className="field-label">{lc.globalDeliveryDesc}</div>
|
||||
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
|
||||
<label className="field-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.send_progress)}
|
||||
onChange={(e) => updateGlobalDeliveryFlag('sendProgress', e.target.checked)}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{lc.sendProgress}
|
||||
</label>
|
||||
<label className="field-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.send_tool_hints)}
|
||||
onChange={(e) => updateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{lc.sendToolHints}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wizard-channel-list">
|
||||
{form.channels.map((channel, idx) => (
|
||||
<div key={`${channel.channel_type}-${idx}`} className="card wizard-channel-card wizard-channel-compact">
|
||||
<div className="row-between">
|
||||
<strong style={{ textTransform: 'uppercase' }}>{channel.channel_type}</strong>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label className="field-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.is_active}
|
||||
onChange={(e) => updateChannel(idx, { is_active: e.target.checked })}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{lc.enabled}
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => removeChannel(idx)}
|
||||
title={lc.remove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderChannelFields(channel, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="row-between">
|
||||
<select className="select" value={newChannelType} onChange={(e) => setNewChannelType(e.target.value as ChannelType)} disabled={addableChannelTypes.length === 0}>
|
||||
{addableChannelTypes.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-secondary btn-sm icon-btn" disabled={addableChannelTypes.length === 0} onClick={addChannel} title={lc.addChannel} aria-label={lc.addChannel}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<span className="field-label">{lc.wizardSectionDesc}</span>
|
||||
<button className="btn btn-primary" onClick={() => setShowChannelModal(false)}>{lc.close}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showToolsConfigModal && (
|
||||
<div className="modal-mask" onClick={() => setShowToolsConfigModal(false)}>
|
||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{ui.toolsSectionTitle}</h3>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>{ui.envParamsDesc}</div>
|
||||
<div className="wizard-channel-list">
|
||||
{envEntries.length === 0 ? (
|
||||
<div className="ops-empty-inline">{ui.noEnvParams}</div>
|
||||
) : (
|
||||
envEntries.map(([key, value]) => (
|
||||
<div key={key} className="card wizard-channel-card wizard-channel-compact">
|
||||
<div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
className="input mono"
|
||||
value={key}
|
||||
readOnly
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type={envVisibleByKey[key] ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||
placeholder={ui.envValue}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
title={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
|
||||
aria-label={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
|
||||
>
|
||||
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => removeEnvParam(key)}
|
||||
title={ui.removeEnvParam}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<input
|
||||
className="input mono"
|
||||
value={envDraftKey}
|
||||
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
||||
placeholder={ui.envKey}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type={envDraftVisible ? 'text' : 'password'}
|
||||
value={envDraftValue}
|
||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||
placeholder={ui.envValue}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setEnvDraftVisible((v) => !v)}
|
||||
title={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
|
||||
aria-label={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
|
||||
>
|
||||
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => {
|
||||
const key = String(envDraftKey || '').trim().toUpperCase();
|
||||
if (!key) return;
|
||||
upsertEnvParam(key, envDraftValue);
|
||||
setEnvDraftValue('');
|
||||
}}
|
||||
title={ui.addEnvParam}
|
||||
aria-label={ui.addEnvParam}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<span className="field-label">{ui.toolsDesc}</span>
|
||||
<button className="btn btn-primary" onClick={() => setShowToolsConfigModal(false)}>{lc.close}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row-between">
|
||||
<button className="btn btn-secondary" disabled={step === 1 || isSubmitting} onClick={() => setStep((s) => Math.max(1, s - 1))}>{ui.prev}</button>
|
||||
{step < 4 ? (
|
||||
<button className="btn btn-primary" onClick={() => void next()}>{ui.next}</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={isSubmitting} onClick={() => void createBot()}>{isSubmitting ? ui.creating : ui.finish}</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { create } from 'zustand';
|
||||
import type { BotState, ChatMessage, BotEvent } from '../types/bot';
|
||||
|
||||
export type AppView = 'images' | 'onboarding' | 'dashboard';
|
||||
export type AppTheme = 'dark' | 'light';
|
||||
export type AppLocale = 'zh' | 'en';
|
||||
|
||||
function normalizeAttachments(rows?: string[]) {
|
||||
return (rows || []).map((v) => String(v || '').trim()).filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
function sameAttachments(a?: string[], b?: string[]) {
|
||||
const aa = normalizeAttachments(a);
|
||||
const bb = normalizeAttachments(b);
|
||||
if (aa.length !== bb.length) return false;
|
||||
return aa.every((v, idx) => v === bb[idx]);
|
||||
}
|
||||
|
||||
interface AppStore {
|
||||
activeBots: Record<string, BotState>;
|
||||
currentView: AppView;
|
||||
theme: AppTheme;
|
||||
locale: AppLocale;
|
||||
setBots: (bots: BotState[]) => void;
|
||||
setView: (view: AppView) => void;
|
||||
setTheme: (theme: AppTheme) => void;
|
||||
setLocale: (locale: AppLocale) => void;
|
||||
updateBotStatus: (botId: string, status: string) => void;
|
||||
updateBotState: (botId: string, state: string, msg: string) => void;
|
||||
addBotLog: (botId: string, log: string) => void;
|
||||
setBotLogs: (botId: string, logs: string[]) => void;
|
||||
addBotMessage: (botId: string, msg: ChatMessage) => void;
|
||||
setBotMessages: (botId: string, msgs: ChatMessage[]) => void;
|
||||
addBotEvent: (botId: string, event: BotEvent) => void;
|
||||
setBotEvents: (botId: string, events: BotEvent[]) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>((set) => ({
|
||||
activeBots: {},
|
||||
currentView: 'dashboard',
|
||||
theme: (localStorage.getItem('nanobot-theme') as AppTheme) || 'dark',
|
||||
locale: (localStorage.getItem('nanobot-locale') as AppLocale) || 'zh',
|
||||
setBots: (bots) =>
|
||||
set((state) => {
|
||||
const nextBots: Record<string, BotState> = {};
|
||||
bots.forEach((bot) => {
|
||||
const prev = state.activeBots[bot.id];
|
||||
const incomingState = (bot.current_state || '').toUpperCase();
|
||||
const prevState = (prev?.current_state || '').toUpperCase();
|
||||
const keepTransientState =
|
||||
bot.docker_status === 'RUNNING' &&
|
||||
(incomingState === '' || incomingState === 'IDLE') &&
|
||||
(prevState === 'THINKING' || prevState === 'TOOL_CALL');
|
||||
const incomingAction = (bot.last_action || '').trim();
|
||||
|
||||
nextBots[bot.id] = {
|
||||
...bot,
|
||||
logs: prev?.logs ?? [],
|
||||
messages: prev?.messages ?? [],
|
||||
events: prev?.events ?? [],
|
||||
current_state: keepTransientState ? prev?.current_state : bot.current_state ?? prev?.current_state,
|
||||
last_action: incomingAction || prev?.last_action,
|
||||
};
|
||||
});
|
||||
return { activeBots: nextBots };
|
||||
}),
|
||||
setView: (view) => set({ currentView: view }),
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem('nanobot-theme', theme);
|
||||
set({ theme });
|
||||
},
|
||||
setLocale: (locale) => {
|
||||
localStorage.setItem('nanobot-locale', locale);
|
||||
set({ locale });
|
||||
},
|
||||
updateBotStatus: (botId, status) =>
|
||||
set((state) => ({
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: { ...state.activeBots[botId], docker_status: status },
|
||||
},
|
||||
})),
|
||||
updateBotState: (botId, stateVal, msg) =>
|
||||
set((state) => ({
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
current_state: stateVal,
|
||||
last_action: msg,
|
||||
},
|
||||
},
|
||||
})),
|
||||
addBotLog: (botId, log) =>
|
||||
set((state) => ({
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
logs: [...(state.activeBots[botId]?.logs || []).slice(-200), log],
|
||||
},
|
||||
},
|
||||
})),
|
||||
setBotLogs: (botId, logs) =>
|
||||
set((state) => ({
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
logs: logs.slice(-300),
|
||||
},
|
||||
},
|
||||
})),
|
||||
addBotMessage: (botId, msg) =>
|
||||
set((state) => {
|
||||
const prev = state.activeBots[botId]?.messages || [];
|
||||
const last = prev[prev.length - 1];
|
||||
// Avoid immediate duplicate push from local send + websocket echo.
|
||||
const dedupeWindowMs = msg.role === 'user' ? 15000 : 5000;
|
||||
if (
|
||||
last &&
|
||||
last.role === msg.role &&
|
||||
last.text === msg.text &&
|
||||
(last.kind || 'final') === (msg.kind || 'final') &&
|
||||
sameAttachments(last.attachments, msg.attachments) &&
|
||||
msg.ts - last.ts < dedupeWindowMs
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
messages: [...prev.slice(-300), msg],
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
setBotMessages: (botId, msgs) =>
|
||||
set((state) => ({
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
messages: msgs.slice(-300),
|
||||
},
|
||||
},
|
||||
})),
|
||||
addBotEvent: (botId, event) =>
|
||||
set((state) => {
|
||||
const prev = state.activeBots[botId]?.events || [];
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.state === event.state && last.text === event.text && (last.channel || '') === (event.channel || '')) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
events: [...prev.slice(-200), event],
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
setBotEvents: (botId, events) =>
|
||||
set((state) => ({
|
||||
activeBots: {
|
||||
...state.activeBots,
|
||||
[botId]: {
|
||||
...state.activeBots[botId],
|
||||
events: events.slice(-200),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
text: string;
|
||||
ts: number;
|
||||
attachments?: string[];
|
||||
kind?: 'progress' | 'final';
|
||||
}
|
||||
|
||||
export interface BotEvent {
|
||||
state: 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO';
|
||||
text: string;
|
||||
ts: number;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface BotState {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_model: string;
|
||||
avatar_skin?: string;
|
||||
docker_status: string;
|
||||
image_tag?: string;
|
||||
llm_provider?: string;
|
||||
llm_model?: string;
|
||||
system_prompt?: string;
|
||||
api_base?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
send_progress?: boolean;
|
||||
send_tool_hints?: boolean;
|
||||
soul_md?: string;
|
||||
agents_md?: string;
|
||||
user_md?: string;
|
||||
tools_md?: string;
|
||||
identity_md?: string;
|
||||
last_action?: string;
|
||||
current_state?: string;
|
||||
logs: string[];
|
||||
messages?: ChatMessage[];
|
||||
events?: BotEvent[];
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'marquee': 'marquee 25s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
marquee: {
|
||||
'0%': { transform: 'translateX(100%)' },
|
||||
'100%': { transform: 'translateX(-100%)' },
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${1:-$ROOT_DIR/.env.prod}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Missing env file: $ENV_FILE"
|
||||
echo "Create it from: $ROOT_DIR/.env.prod.example"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy] using env: $ENV_FILE"
|
||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.prod.yml" up -d --build
|
||||
|
||||
echo "[deploy] service status"
|
||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.prod.yml" ps
|
||||
|
||||
echo "[deploy] done"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${1:-$ROOT_DIR/.env.prod}"
|
||||
|
||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.prod.yml" down
|
||||
Loading…
Reference in New Issue