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