Initial commit: Hermes Agent 多实例管理与 Web 控制台
基于官方 nousresearch/hermes-agent 镜像的 CLI、Docker Compose 与 Web 管理界面,支持多实例部署;敏感配置与实例数据由 .gitignore 排除。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# 复制为 .env 后填写(本文件可提交,.env 已在 .gitignore)
|
||||||
|
|
||||||
|
# 官方预构建镜像(推荐,无需 clone 源码、无需 docker build)
|
||||||
|
HERMES_IMAGE=nousresearch/hermes-agent:latest
|
||||||
|
|
||||||
|
# 可选:实例数据父目录
|
||||||
|
# HERMES_HOME_DATA_DIR=/path/to/parent
|
||||||
|
|
||||||
|
# 可选:从源码本地构建(耗时长;需 clone 仓库并设置 HERMES_SOURCE)
|
||||||
|
# HERMES_USE_LOCAL_BUILD=1
|
||||||
|
# HERMES_SOURCE=/absolute/path/to/hermes-agent
|
||||||
|
|
||||||
|
# HERMES_CONTAINER_UID / HERMES_CONTAINER_GID
|
||||||
|
# HERMES_AGENT_WEB_ADMIN_PASSWORD=你的强密码
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 本地实例数据(默认 hermes-agent-instances/,也可由 OPENCLAW_HOME_DATA_DIR 指向其它路径)
|
||||||
|
hermes-agent-instances/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# hermes-agent-home
|
||||||
|
|
||||||
|
在本机用 **Docker Compose** 管理多个 **[NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent)** 实例:CLI + 可选 Web 管理台。
|
||||||
|
|
||||||
|
**不是 OpenClaw。** 底层镜像与服务来自官方 `hermes-agent` 仓库;每实例独立数据目录挂载到容器 `/opt/data`(`config.yaml`、`.env`、`sessions/` 等)。
|
||||||
|
|
||||||
|
## 与 openclaw-home 的区别
|
||||||
|
|
||||||
|
| 项目 | openclaw-home | hermes-agent-home(本仓库) |
|
||||||
|
|------|---------------|---------------------------|
|
||||||
|
| 上游仓库 | OpenClaw | **NousResearch/hermes-agent** |
|
||||||
|
| Compose 服务 | `openclaw-gateway` | **`gateway`** |
|
||||||
|
| 配置 | `config/openclaw.json` | **`data/config.yaml`** |
|
||||||
|
| 网关/Dashboard 端口 | 18780+N / 18790+N | **8641+N / 9118+N**(实例 1 → 8642 / 9119) |
|
||||||
|
| Compose 项目名 | `claw-<N>` | `hermes-<N>` |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/NousResearch/hermes-agent.git /path/to/hermes-agent
|
||||||
|
|
||||||
|
cd /path/to/hermes-agent-home
|
||||||
|
cp .env.example .env # 设置 HERMES_SOURCE=/path/to/hermes-agent
|
||||||
|
|
||||||
|
./hermes-agent-cli.sh check
|
||||||
|
./hermes-agent-cli.sh compose-build 1 # 首次构建镜像(较久)
|
||||||
|
./hermes-agent-cli.sh setup 1 # 交互配置 API 密钥等(或 Web「Hermes 引导」)
|
||||||
|
./hermes-agent-cli.sh run 1
|
||||||
|
|
||||||
|
# 实例 1:API http://127.0.0.1:8642 · Dashboard http://127.0.0.1:9119
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
见 [hermes-agent-web/README.md](hermes-agent-web/README.md)。默认 http://127.0.0.1:8781
|
||||||
|
|
||||||
|
## 从旧版(OpenClaw 兼容层)迁移
|
||||||
|
|
||||||
|
若此前用 `OPENCLAW_SOURCE` 跑过实例,请**销毁旧实例**后按上表重建;旧目录 `hermes-agent-instances/<N>/config/` 与 Hermes 的 `data/` 布局不兼容。
|
||||||
|
|
||||||
|
文档:https://hermes-agent.nousresearch.com/docs/user-guide/docker
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
mkdir -p ~/.openclaw && python3 - <<'PY'
|
||||||
|
import json, os, pathlib, copy
|
||||||
|
|
||||||
|
base = pathlib.Path(os.path.expanduser("~/.openclaw"))
|
||||||
|
openclaw_path = base / "openclaw.json"
|
||||||
|
approvals_path = base / "exec-approvals.json"
|
||||||
|
|
||||||
|
def load_json(path, default):
|
||||||
|
if not path.exists():
|
||||||
|
return copy.deepcopy(default)
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return copy.deepcopy(default)
|
||||||
|
|
||||||
|
# 1) 修 openclaw.json
|
||||||
|
cfg = load_json(openclaw_path, {})
|
||||||
|
cfg.setdefault("tools", {})
|
||||||
|
cfg["tools"].setdefault("exec", {})
|
||||||
|
cfg["tools"]["exec"]["security"] = "full"
|
||||||
|
cfg["tools"]["exec"]["ask"] = "off"
|
||||||
|
|
||||||
|
# 可选但实用:把常见会挡住 skill 的 deny 清掉
|
||||||
|
agents = cfg.get("agents", {}).get("list", [])
|
||||||
|
if isinstance(agents, list):
|
||||||
|
for agent in agents:
|
||||||
|
if not isinstance(agent, dict):
|
||||||
|
continue
|
||||||
|
tools = agent.setdefault("tools", {})
|
||||||
|
deny = tools.get("deny", [])
|
||||||
|
if isinstance(deny, list):
|
||||||
|
tools["deny"] = []
|
||||||
|
|
||||||
|
openclaw_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
# 2) 修 exec-approvals.json
|
||||||
|
default_approvals = {
|
||||||
|
"version": 1,
|
||||||
|
"socket": {
|
||||||
|
"path": str(base / "exec-approvals.sock"),
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"security": "full",
|
||||||
|
"ask": "off",
|
||||||
|
"askFallback": "full",
|
||||||
|
"autoAllowSkills": True
|
||||||
|
},
|
||||||
|
"agents": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ap = load_json(approvals_path, default_approvals)
|
||||||
|
|
||||||
|
# 保留已有 socket,避免你本机原来的 socket/token 被抹掉
|
||||||
|
if not isinstance(ap, dict):
|
||||||
|
ap = copy.deepcopy(default_approvals)
|
||||||
|
|
||||||
|
ap["version"] = 1
|
||||||
|
ap.setdefault("socket", {})
|
||||||
|
ap["socket"].setdefault("path", str(base / "exec-approvals.sock"))
|
||||||
|
ap["socket"].setdefault("token", "")
|
||||||
|
|
||||||
|
ap.setdefault("defaults", {})
|
||||||
|
ap["defaults"]["security"] = "full"
|
||||||
|
ap["defaults"]["ask"] = "off"
|
||||||
|
ap["defaults"]["askFallback"] = "full"
|
||||||
|
ap["defaults"]["autoAllowSkills"] = True
|
||||||
|
|
||||||
|
ap.setdefault("agents", {})
|
||||||
|
if isinstance(ap["agents"], dict):
|
||||||
|
for agent_name, agent_cfg in list(ap["agents"].items()):
|
||||||
|
if isinstance(agent_cfg, dict):
|
||||||
|
agent_cfg["security"] = "full"
|
||||||
|
agent_cfg["ask"] = "off"
|
||||||
|
agent_cfg["askFallback"] = "full"
|
||||||
|
agent_cfg["autoAllowSkills"] = True
|
||||||
|
|
||||||
|
approvals_path.write_text(json.dumps(ap, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
print("DONE")
|
||||||
|
print(openclaw_path)
|
||||||
|
print(approvals_path)
|
||||||
|
PY
|
||||||
|
|
||||||
|
openclaw gateway start
|
||||||
|
echo '----- GATEWAY STATUS -----'
|
||||||
|
openclaw gateway status
|
||||||
|
echo '----- APPROVALS EFFECTIVE -----'
|
||||||
|
openclaw approvals get --gateway || true
|
||||||
|
echo '----- OPENCLAW EXEC CONFIG -----'
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, pathlib
|
||||||
|
p = pathlib.Path(os.path.expanduser("~/.openclaw/openclaw.json"))
|
||||||
|
cfg = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
print(json.dumps(cfg.get("tools", {}).get("exec", {}), ensure_ascii=False, indent=2))
|
||||||
|
PY
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 可选:HERMES_USE_LOCAL_BUILD=1 时叠加,从 HERMES_SOURCE 源码构建
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
build:
|
||||||
|
context: ${HERMES_BUILD_CONTEXT}
|
||||||
|
dockerfile: Dockerfile
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 多实例:默认使用 Docker Hub 官方镜像(无需本地 build)
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
image: ${HERMES_IMAGE:-nousresearch/hermes-agent:latest}
|
||||||
|
pull_policy: if_not_present
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HERMES_GATEWAY_PORT}:8642"
|
||||||
|
- "${HERMES_DASHBOARD_PORT}:9119"
|
||||||
|
volumes:
|
||||||
|
- "${HERMES_DATA_DIR}:/opt/data"
|
||||||
|
environment:
|
||||||
|
HERMES_UID: "${HERMES_UID:-10000}"
|
||||||
|
HERMES_GID: "${HERMES_GID:-10000}"
|
||||||
|
HERMES_DASHBOARD: "${HERMES_DASHBOARD:-1}"
|
||||||
|
HERMES_DASHBOARD_HOST: "0.0.0.0"
|
||||||
|
HERMES_DASHBOARD_PORT: "9119"
|
||||||
|
shm_size: 1gb
|
||||||
|
command: ["gateway", "run"]
|
||||||
Executable
+490
@@ -0,0 +1,490 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# NousResearch Hermes Agent 多实例管理(Docker Compose)
|
||||||
|
# 用法: ./hermes-agent-cli.sh help
|
||||||
|
#
|
||||||
|
# 默认拉取官方镜像 nousresearch/hermes-agent:latest,无需本地 build。
|
||||||
|
# 仅当 HERMES_USE_LOCAL_BUILD=1 时需设置 HERMES_SOURCE 指向源码仓库。
|
||||||
|
|
||||||
|
if [ -z "${BASH_VERSION-}" ]; then
|
||||||
|
exec /usr/bin/env bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
ENV_FILE="$SCRIPT_DIR/.env"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${hermes_source:-}" ] && [ -z "${HERMES_SOURCE:-}" ]; then
|
||||||
|
HERMES_SOURCE="$hermes_source"
|
||||||
|
export HERMES_SOURCE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 宿主机 UID/GID,供容器 entrypoint 映射(默认当前用户)
|
||||||
|
HERMES_CONTAINER_UID="${HERMES_CONTAINER_UID:-$(id -u)}"
|
||||||
|
HERMES_CONTAINER_GID="${HERMES_CONTAINER_GID:-$(id -g)}"
|
||||||
|
HERMES_IMAGE="${HERMES_IMAGE:-nousresearch/hermes-agent:latest}"
|
||||||
|
HERMES_DASHBOARD="${HERMES_DASHBOARD:-1}"
|
||||||
|
HERMES_USE_LOCAL_BUILD="${HERMES_USE_LOCAL_BUILD:-0}"
|
||||||
|
|
||||||
|
HM_DATA_ROOT="${HERMES_HOME_DATA_DIR:-$SCRIPT_DIR/hermes-agent-instances}"
|
||||||
|
COMPOSE_INSTANCE_FILE="$SCRIPT_DIR/docker-compose.instance.yml"
|
||||||
|
COMPOSE_BUILD_FILE="$SCRIPT_DIR/docker-compose.instance.build.yml"
|
||||||
|
COMPOSE_FILES=()
|
||||||
|
COMPOSE_INIT_DONE=
|
||||||
|
|
||||||
|
if [[ "${1:-}" =~ ^[0-9]+$ ]] && [ "${2:-}" = "" ]; then
|
||||||
|
set -- run "$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
MARK_BOOTSTRAP_STUB=".hermes-bootstrap-stub"
|
||||||
|
MARK_SETUP_DONE=".hermes-setup-completed"
|
||||||
|
MARK_SETUP_DECLINED=".hermes-setup-declined"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法: hermes-agent-cli.sh <子命令> [参数]
|
||||||
|
|
||||||
|
run [--quick|--wizard|--build] <序号> 启动 gateway;--build 缺镜像时先构建再启动(耗时长)
|
||||||
|
bootstrap <序号> 创建数据目录(首次由 entrypoint 初始化 config)
|
||||||
|
setup <序号> 交互运行 hermes setup 向导(写入 API 密钥等)
|
||||||
|
restart <序号> 重启 gateway
|
||||||
|
stop <序号> 停止 gateway
|
||||||
|
status <序号> 输出 gateway 的 docker compose ps --format json
|
||||||
|
check Docker 可用 + compose 配置可解析
|
||||||
|
compose-build|image-build|pull <序号> 拉取官方镜像(默认);HERMES_USE_LOCAL_BUILD=1 时为本地 build
|
||||||
|
list-ids|instances 列出实例序号(数据目录 ∪ Docker 项目 hermes-<N>)
|
||||||
|
destroy|destory <序号> compose down 并删除宿主机该实例 data 目录
|
||||||
|
exec <序号> 进入运行中 gateway 容器的 shell
|
||||||
|
log|logs <序号> 跟踪 gateway 日志
|
||||||
|
cli <序号> [参数...] 在容器内执行 hermes(如 channels、config 子命令)
|
||||||
|
config <序号> [参数...] 同 cli config …
|
||||||
|
help, --help, -h 本说明
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 默认镜像: nousresearch/hermes-agent:latest(docker pull,无需 clone 源码)
|
||||||
|
- 本地构建: .env 设 HERMES_USE_LOCAL_BUILD=1 且 HERMES_SOURCE 指向源码仓库
|
||||||
|
- 每实例数据: HERMES_HOME_DATA_DIR/<序号>/data → 容器 /opt/data(config.yaml、.env、sessions/…)
|
||||||
|
- Compose 项目名: hermes-<序号>;服务名: gateway;容器名如 hermes-<序号>-gateway-1
|
||||||
|
- 端口: 网关 API 8641+<序号>(实例 1 → 8642);Dashboard 9118+<序号>(实例 1 → 9119)
|
||||||
|
- 文档: https://hermes-agent.nousresearch.com/docs/user-guide/docker
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_paths() {
|
||||||
|
_I_ID="$1"
|
||||||
|
_I_GATE=$((8641 + _I_ID))
|
||||||
|
_I_DASH=$((9118 + _I_ID))
|
||||||
|
_I_DATA="$HM_DATA_ROOT/$_I_ID/data"
|
||||||
|
mkdir -p "$_I_DATA"
|
||||||
|
instance_bind_mounts_chown "$_I_DATA"
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_bind_mounts_chown() {
|
||||||
|
local d="$1"
|
||||||
|
if [ "${HERMES_SKIP_BIND_CHOWN:-}" = 1 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown -R "${HERMES_CONTAINER_UID}:${HERMES_CONTAINER_GID}" "$d"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_has_config() {
|
||||||
|
local data="$1"
|
||||||
|
[ -f "$data/config.yaml" ] || [ -f "$data/.env" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_ensure() {
|
||||||
|
if [ -n "$COMPOSE_INIT_DONE" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ ! -f "$COMPOSE_INSTANCE_FILE" ]; then
|
||||||
|
echo "缺少 $COMPOSE_INSTANCE_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
COMPOSE_FILES=(-f "$COMPOSE_INSTANCE_FILE")
|
||||||
|
if [ "$HERMES_USE_LOCAL_BUILD" = 1 ] || [ "$HERMES_USE_LOCAL_BUILD" = true ]; then
|
||||||
|
local root="${HERMES_SOURCE:-}"
|
||||||
|
if [ -z "$root" ]; then
|
||||||
|
echo "HERMES_USE_LOCAL_BUILD=1 时请在 $ENV_FILE 设置 HERMES_SOURCE(源码仓库根目录)。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -d "$root" ]; then
|
||||||
|
echo "HERMES_SOURCE 不是目录: $root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
root="$(cd "$root" && pwd)"
|
||||||
|
if [ ! -f "$root/Dockerfile" ]; then
|
||||||
|
echo "HERMES_SOURCE 下未找到 Dockerfile。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$COMPOSE_BUILD_FILE" ]; then
|
||||||
|
echo "缺少 $COMPOSE_BUILD_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export HERMES_SOURCE="$root"
|
||||||
|
export HERMES_BUILD_CONTEXT="$root"
|
||||||
|
COMPOSE_FILES+=(-f "$COMPOSE_BUILD_FILE")
|
||||||
|
fi
|
||||||
|
COMPOSE_INIT_DONE=1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_id() {
|
||||||
|
local id="${1:-}"
|
||||||
|
if [ -z "$id" ] || ! [[ "$id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "请提供数字序号,例如:$0 ${2:-run} 1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_project() {
|
||||||
|
printf 'hermes-%s' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_for_instance() {
|
||||||
|
local id="$1"
|
||||||
|
shift
|
||||||
|
instance_paths "$id"
|
||||||
|
HERMES_DATA_DIR="$_I_DATA" \
|
||||||
|
HERMES_GATEWAY_PORT="$_I_GATE" \
|
||||||
|
HERMES_DASHBOARD_PORT="$_I_DASH" \
|
||||||
|
HERMES_UID="$HERMES_CONTAINER_UID" \
|
||||||
|
HERMES_GID="$HERMES_CONTAINER_GID" \
|
||||||
|
HERMES_IMAGE="$HERMES_IMAGE" \
|
||||||
|
HERMES_DASHBOARD="$HERMES_DASHBOARD" \
|
||||||
|
HERMES_BUILD_CONTEXT="${HERMES_BUILD_CONTEXT:-${HERMES_SOURCE:-}}" \
|
||||||
|
docker compose \
|
||||||
|
"${COMPOSE_FILES[@]}" \
|
||||||
|
--project-directory "$SCRIPT_DIR" \
|
||||||
|
-p "$(compose_project "$id")" \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_service_container_id() {
|
||||||
|
local id="$1"
|
||||||
|
local svc="${2:-gateway}"
|
||||||
|
compose_for_instance "$id" ps -q "$svc" 2>/dev/null | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
hermes_image_ready() {
|
||||||
|
docker image inspect "${HERMES_IMAGE}" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_bootstrap() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
instance_paths "$ID"
|
||||||
|
echo "实例 #${ID}: 已创建数据目录 ${_I_DATA}(首次启动时 entrypoint 会写入默认 config.yaml / .env)。"
|
||||||
|
touch "$_I_DATA/$MARK_BOOTSTRAP_STUB"
|
||||||
|
rm -f "$_I_DATA/$MARK_SETUP_DONE" "$_I_DATA/$MARK_SETUP_DECLINED"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_setup() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
instance_paths "$ID"
|
||||||
|
if [ "${2:-}" = "--from-run" ]; then
|
||||||
|
echo "实例 #${ID}: 进入 Hermes 官方 setup 向导…"
|
||||||
|
else
|
||||||
|
echo "实例 #${ID}: hermes setup(完成后可 $0 run ${ID})"
|
||||||
|
fi
|
||||||
|
local run_opts=(run --rm --no-deps)
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
run_opts+=(-it)
|
||||||
|
else
|
||||||
|
run_opts+=(-T)
|
||||||
|
fi
|
||||||
|
compose_for_instance "$ID" "${run_opts[@]}" gateway setup
|
||||||
|
touch "$_I_DATA/$MARK_SETUP_DONE"
|
||||||
|
rm -f "$_I_DATA/$MARK_BOOTSTRAP_STUB" "$_I_DATA/$MARK_SETUP_DECLINED"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
local RUN_MODE="${2:-}"
|
||||||
|
instance_paths "$ID"
|
||||||
|
local MY_DATA="$_I_DATA"
|
||||||
|
|
||||||
|
run_setup_first() {
|
||||||
|
cmd_setup "$ID" --from-run || exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$RUN_MODE" = wizard ]; then
|
||||||
|
if [ ! -t 1 ]; then
|
||||||
|
echo "实例 #${ID}: --wizard 需要 TTY。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
run_setup_first
|
||||||
|
elif ! instance_has_config "$MY_DATA"; then
|
||||||
|
if [ "$RUN_MODE" = quick ]; then
|
||||||
|
echo "实例 #${ID}: 尚无 config,--quick 仅创建目录并启动(entrypoint 会初始化)。" >&2
|
||||||
|
cmd_bootstrap "$ID" || exit $?
|
||||||
|
elif [ -t 1 ]; then
|
||||||
|
run_setup_first
|
||||||
|
else
|
||||||
|
echo "实例 #${ID}: 无 TTY,已 bootstrap 后启动;请在终端执行 $0 setup ${ID}。" >&2
|
||||||
|
cmd_bootstrap "$ID" || exit $?
|
||||||
|
fi
|
||||||
|
elif \
|
||||||
|
[ -f "$MY_DATA/$MARK_BOOTSTRAP_STUB" ] && \
|
||||||
|
[ ! -f "$MY_DATA/$MARK_SETUP_DONE" ] && \
|
||||||
|
[ "$RUN_MODE" != quick ] && \
|
||||||
|
[ -t 1 ]; then
|
||||||
|
run_setup_first
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "正在启动实例 #${ID}(Nous Hermes Agent gateway)…"
|
||||||
|
echo "数据目录: $MY_DATA"
|
||||||
|
echo "端口: API http://127.0.0.1:$((8641 + ID)):8642 · Dashboard http://127.0.0.1:$((9118 + ID)):9119"
|
||||||
|
|
||||||
|
if [ "$RUN_MODE" = build ]; then
|
||||||
|
if ! hermes_image_ready; then
|
||||||
|
echo "实例 #${ID}: 镜像 ${HERMES_IMAGE} 不存在,开始构建(可能需 10–30 分钟)…" >&2
|
||||||
|
cmd_compose_build "$ID"
|
||||||
|
fi
|
||||||
|
compose_for_instance "$ID" up -d --no-build gateway
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! hermes_image_ready; then
|
||||||
|
echo "本地尚无镜像 ${HERMES_IMAGE}。" >&2
|
||||||
|
echo "请先执行: $0 compose-build ${ID} # 默认从 Docker Hub 拉取官方镜像" >&2
|
||||||
|
echo "或在 Web 点「拉取镜像」;完成后再点「启动」。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose_for_instance "$ID" up -d --no-build gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_restart() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
compose_for_instance "$ID" restart gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
compose_for_instance "$ID" stop gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
compose_for_instance "$ID" ps -a --format json gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_check() {
|
||||||
|
compose_ensure
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "无法连接 Docker 守护进程(请启动 Docker Desktop、Colima 或 dockerd)。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local _chk_td _chk_pr
|
||||||
|
_chk_td="$(mktemp -d "${TMPDIR:-/tmp}/hermes-agent-compose-check.XXXXXX")"
|
||||||
|
mkdir -p "$_chk_td/data"
|
||||||
|
_chk_pr="hermes-agent-preflight-$$"
|
||||||
|
if ! \
|
||||||
|
HERMES_DATA_DIR="$_chk_td/data" \
|
||||||
|
HERMES_GATEWAY_PORT=8642 \
|
||||||
|
HERMES_DASHBOARD_PORT=9119 \
|
||||||
|
HERMES_UID="$HERMES_CONTAINER_UID" \
|
||||||
|
HERMES_GID="$HERMES_CONTAINER_GID" \
|
||||||
|
HERMES_IMAGE="$HERMES_IMAGE" \
|
||||||
|
HERMES_DASHBOARD="$HERMES_DASHBOARD" \
|
||||||
|
docker compose "${COMPOSE_FILES[@]}" --project-directory "$SCRIPT_DIR" -p "$_chk_pr" config >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
echo "docker compose config 解析失败。" >&2
|
||||||
|
HERMES_DATA_DIR="$_chk_td/data" \
|
||||||
|
HERMES_GATEWAY_PORT=8642 \
|
||||||
|
HERMES_DASHBOARD_PORT=9119 \
|
||||||
|
HERMES_UID="$HERMES_CONTAINER_UID" \
|
||||||
|
HERMES_GID="$HERMES_CONTAINER_GID" \
|
||||||
|
HERMES_IMAGE="$HERMES_IMAGE" \
|
||||||
|
HERMES_DASHBOARD="$HERMES_DASHBOARD" \
|
||||||
|
docker compose "${COMPOSE_FILES[@]}" --project-directory "$SCRIPT_DIR" -p "$_chk_pr" config || true
|
||||||
|
rm -rf "$_chk_td"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf "$_chk_td"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list_ids() {
|
||||||
|
local -a ids=()
|
||||||
|
local d n line proj
|
||||||
|
if [ -d "$HM_DATA_ROOT" ]; then
|
||||||
|
shopt -s nullglob
|
||||||
|
for d in "$HM_DATA_ROOT"/*; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
n="$(basename "$d")"
|
||||||
|
if [[ "$n" =~ ^[0-9]+$ ]]; then
|
||||||
|
ids+=("$n")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
fi
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
if [[ "$line" =~ ^hermes-([0-9]+)-gateway ]]; then
|
||||||
|
ids+=("${BASH_REMATCH[1]}")
|
||||||
|
fi
|
||||||
|
done < <(docker ps -a --no-trunc --format '{{.Names}}' 2>/dev/null || true)
|
||||||
|
while IFS= read -r proj; do
|
||||||
|
[ -z "$proj" ] && continue
|
||||||
|
if [[ "$proj" =~ ^hermes-([0-9]+)$ ]]; then
|
||||||
|
ids+=("${BASH_REMATCH[1]}")
|
||||||
|
fi
|
||||||
|
done < <(docker compose ls -a --format '{{.Name}}' 2>/dev/null || true)
|
||||||
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${ids[@]}" | sort -nu
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_destroy() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
local INSTANCE_DIR="$HM_DATA_ROOT/$ID"
|
||||||
|
echo "实例 #${ID}: docker compose down …" >&2
|
||||||
|
compose_for_instance "$ID" down --remove-orphans
|
||||||
|
if [ -d "$INSTANCE_DIR" ]; then
|
||||||
|
echo "实例 #${ID}: 删除 ${INSTANCE_DIR} …" >&2
|
||||||
|
rm -rf "$INSTANCE_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_exec() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
local cid
|
||||||
|
cid="$(compose_service_container_id "$ID" gateway)"
|
||||||
|
if [ -z "$cid" ]; then
|
||||||
|
echo "实例 #${ID}: gateway 未运行(先 $0 run ${ID})。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker exec -it -w /opt/data "$cid" /bin/sh
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_log() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
compose_for_instance "$ID" logs -f --tail 200 gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_compose_build() {
|
||||||
|
compose_ensure
|
||||||
|
local ID="$1"
|
||||||
|
require_id "$ID" compose-build
|
||||||
|
if [ "$HERMES_USE_LOCAL_BUILD" = 1 ] || [ "$HERMES_USE_LOCAL_BUILD" = true ]; then
|
||||||
|
echo "在 ${HERMES_SOURCE} 本地构建镜像 ${HERMES_IMAGE}(可能需 10–30 分钟)…" >&2
|
||||||
|
compose_for_instance "$ID" build
|
||||||
|
else
|
||||||
|
if hermes_image_ready; then
|
||||||
|
echo "镜像 ${HERMES_IMAGE} 已存在于本机,跳过拉取(如需更新可: docker pull ${HERMES_IMAGE})。" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "从 Docker Hub 拉取官方镜像 ${HERMES_IMAGE} …" >&2
|
||||||
|
docker pull "${HERMES_IMAGE}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
hermes_in_container() {
|
||||||
|
local ID="$1"
|
||||||
|
shift
|
||||||
|
compose_ensure
|
||||||
|
instance_paths "$ID"
|
||||||
|
local cid
|
||||||
|
cid="$(compose_service_container_id "$ID" gateway)"
|
||||||
|
if [ -n "$cid" ]; then
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
docker exec -it -w /opt/data "$cid" hermes "$@"
|
||||||
|
else
|
||||||
|
docker exec -i -w /opt/data "$cid" hermes "$@"
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local run_opts=(run --rm --no-deps)
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
run_opts+=(-it)
|
||||||
|
else
|
||||||
|
run_opts+=(-T)
|
||||||
|
fi
|
||||||
|
compose_for_instance "$ID" "${run_opts[@]}" gateway "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_cli() {
|
||||||
|
local ID="$1"
|
||||||
|
shift
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "用法: $0 cli <序号> <子命令> [参数…]" >&2
|
||||||
|
echo "示例: $0 cli 1 config" >&2
|
||||||
|
echo " $0 setup 1 # 首次配置向导" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
hermes_in_container "$ID" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_config() {
|
||||||
|
local ID="$1"
|
||||||
|
shift
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
hermes_in_container "$ID" config
|
||||||
|
else
|
||||||
|
hermes_in_container "$ID" config "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
case "$CMD" in
|
||||||
|
help|--help|-h) usage ;;
|
||||||
|
"") usage >&2; exit 1 ;;
|
||||||
|
run)
|
||||||
|
case "${2:-}" in
|
||||||
|
--quick) require_id "${3:-}" run; cmd_run "$3" quick ;;
|
||||||
|
--wizard) require_id "${3:-}" run; cmd_run "$3" wizard ;;
|
||||||
|
--build) require_id "${3:-}" run; cmd_run "$3" build ;;
|
||||||
|
*) require_id "${2:-}" run; cmd_run "$2" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
bootstrap) require_id "${2:-}" bootstrap; cmd_bootstrap "$2" ;;
|
||||||
|
setup) require_id "${2:-}" setup; cmd_setup "$2" ;;
|
||||||
|
restart) require_id "${2:-}" restart; cmd_restart "$2" ;;
|
||||||
|
stop) require_id "${2:-}" stop; cmd_stop "$2" ;;
|
||||||
|
status) require_id "${2:-}" status; cmd_status "$2" ;;
|
||||||
|
check) cmd_check ;;
|
||||||
|
compose-build|image-build|pull) require_id "${2:-}" compose-build; cmd_compose_build "$2" ;;
|
||||||
|
list-ids|instances) cmd_list_ids ;;
|
||||||
|
destroy|destory) require_id "${2:-}" destroy; cmd_destroy "$2" ;;
|
||||||
|
exec) require_id "${2:-}" exec; cmd_exec "$2" ;;
|
||||||
|
log|logs) require_id "${2:-}" log; cmd_log "$2" ;;
|
||||||
|
cli)
|
||||||
|
require_id "${2:-}" cli
|
||||||
|
_id="$2"
|
||||||
|
shift 2
|
||||||
|
cmd_cli "$_id" "$@"
|
||||||
|
;;
|
||||||
|
config)
|
||||||
|
require_id "${2:-}" config
|
||||||
|
_id="$2"
|
||||||
|
shift 2
|
||||||
|
cmd_config "$_id" "$@"
|
||||||
|
;;
|
||||||
|
channels|channel|devices|allowall)
|
||||||
|
echo "子命令 $CMD 为旧版 OpenClaw 专用;请使用: $0 cli <序号> … 或查阅 Hermes 文档。" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "未知子命令: $CMD" >&2
|
||||||
|
echo "运行 $0 help 查看说明。" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Hermes Agent Web
|
||||||
|
|
||||||
|
在浏览器里管理本机多个 Hermes Agent(OpenClaw 兼容)实例:**列表与状态、新建、bootstrap 后启动、停止、重启、销毁**;**引导**在 **「Hermes 引导」** 页用浏览器内终端(PTY + xterm.js)完成 `setup`、`run`、`onboard`、`config`、`channels` 等。服务端用 **Flask + Jinja2**,**单进程 Python** 即可运行。
|
||||||
|
|
||||||
|
所有与 Docker Compose / 实例生命周期相关的操作都通过上级目录的 [`hermes-agent-cli.sh`](../hermes-agent-cli.sh) 完成(含 `check`、`status`、`stop`、`run`、`restart`、`destroy`、`bootstrap`、`cli` 透传容器内 `openclaw-cli`)。实例配置可通过 **`/instance/<序号>/config`** 编辑宿主机上的 `hermes-agent-instances/<序号>/config/openclaw.json`(保存前 JSON 校验并备份 `.bak`)。**销毁**会同时删除该序号下整份实例数据目录。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- 本机已安装 **Docker**,且 `docker compose` 可用。
|
||||||
|
- 已配置 **`hermes-agent-home/.env`**(与 CLI 相同),至少设置 **`HERMES_SOURCE`** 或 **`OPENCLAW_SOURCE`**(OpenClaw 仓库根目录,内含 `docker-compose.yml`)。
|
||||||
|
- 可选 **`HERMES_HOME_DATA_DIR`**:实例数据父目录;不设置时默认使用 `hermes-agent-home/hermes-agent-instances/`。
|
||||||
|
|
||||||
|
## 安装与启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/hermes-agent-home/hermes-agent-web
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
export HERMES_AGENT_WEB_ADMIN_PASSWORD='你的强密码'
|
||||||
|
.venv/bin/python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问终端中打印的地址(默认 **http://127.0.0.1:8781**)。
|
||||||
|
|
||||||
|
默认监听 **`127.0.0.1`**,仅本机可访问;若需局域网访问,设置 `HERMES_AGENT_WEB_HOST=0.0.0.0` 并自行做好访问控制。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `HERMES_AGENT_WEB_ADMIN_PASSWORD` | 登录密码(建议必填)。也可设为 Werkzeug 生成的哈希串(以 `pbkdf2:` / `scrypt:` 开头) |
|
||||||
|
| `HERMES_AGENT_WEB_ADMIN_USER` | 登录用户名,默认 `hermes` |
|
||||||
|
| `HERMES_AGENT_WEB_SECRET_KEY` | Flask session 签名密钥;不设置则每次进程启动随机,**重启后已登录 session 会失效** |
|
||||||
|
| `HERMES_AGENT_WEB_HOST` | 监听地址,默认 `127.0.0.1` |
|
||||||
|
| `HERMES_AGENT_WEB_PORT` | 端口,默认 `8781` |
|
||||||
|
| `HERMES_AGENT_WEB_LOG_CLI` | 是否在终端打印每次执行的 `hermes-agent-cli.sh` 命令、`exit` 码与 stdout/stderr(默认开启;`0` / `false` 关闭)。单条输出超过约 64KB 会截断 |
|
||||||
|
|
||||||
|
应用会加载 **`hermes-agent-home/.env`**(与 `HERMES_SOURCE` 等共用),无需在 `hermes-agent-web` 目录再复制一份,除非你希望单独覆盖。
|
||||||
|
|
||||||
|
## 功能简述
|
||||||
|
|
||||||
|
- **实例列表**:序号为 **数据目录下数字文件夹** 与 **Docker 里仍存在的 `hermes-<N>` 项目 / `hermes-<N>-openclaw-gateway-*` 容器** 的并集(**已停止的容器**也会出现在列表里)。与同目录 `./hermes-agent-cli.sh list-ids` 一致。
|
||||||
|
- **新建实例**:分配新序号、创建目录并执行脚本的 `bootstrap`。
|
||||||
|
- **启动**:`run --quick <序号>`。
|
||||||
|
- **停止 / 重启 / 销毁**:分别对应脚本的 `stop`、`restart`、`destroy`(**销毁**会先 `compose down`,再 **`rm -rf` 宿主上该实例目录**,不可恢复)。
|
||||||
|
- **Hermes 引导**:在实例行点 **「Hermes 引导」** 打开 **`/instance/<序号>/guide`**。进入后工作目录为 **hermes-agent-home**,可执行 `./hermes-agent-cli.sh setup`、`cli <序号> onboard` 等;旧链接 **`/instance/<N>/channels`** 会重定向到本引导页。
|
||||||
|
|
||||||
|
## CLI 对照(调试)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/hermes-agent-home
|
||||||
|
./hermes-agent-cli.sh check
|
||||||
|
./hermes-agent-cli.sh status 1
|
||||||
|
./hermes-agent-cli.sh stop 1
|
||||||
|
./hermes-agent-cli.sh cli 1 --help
|
||||||
|
docker ps -a --filter name=openclaw-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
更多子命令见脚本内 `help`。
|
||||||
|
|
||||||
|
## Docker 里找不到网关?
|
||||||
|
|
||||||
|
本仓库脚本为**每个实例**创建独立 Compose **项目名** `hermes-<序号>`,网关服务名为 **`openclaw-gateway`**,容器名大致为 **`hermes-<序号>-openclaw-gateway-1`**。在 Docker Desktop 可搜 **`hermes-1`**(或你的序号)或 **`openclaw-gateway`**。执行 `docker ps -a --filter name=hermes-1-openclaw-gateway` 核对。
|
||||||
@@ -0,0 +1,927 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Hermes Agent Web 管理端:单进程 Flask(模板渲染)。对实例的启停、状态、compose 校验等均只调用
|
||||||
|
hermes-agent-home/hermes-agent-cli.sh;config.yaml 编辑为直接写宿主机 data 卷;destroy 会删除该实例整目录。
|
||||||
|
|
||||||
|
实例目录下可有 ``web-meta.json``(与 ``data/`` 同级),由本管理台维护,用于所属人等标注,
|
||||||
|
不写入 ``config.yaml``,避免与网关配置混杂。
|
||||||
|
|
||||||
|
环境变量(可选,见文件顶注释):
|
||||||
|
HERMES_AGENT_WEB_SECRET_KEY Flask session 密钥
|
||||||
|
HERMES_AGENT_WEB_ADMIN_USER 登录用户名(默认 hermes)
|
||||||
|
HERMES_AGENT_WEB_ADMIN_PASSWORD 登录密码(必填)
|
||||||
|
HERMES_AGENT_WEB_HOST / HERMES_AGENT_WEB_PORT 监听地址(默认 127.0.0.1:8781)
|
||||||
|
HERMES_AGENT_WEB_LOG_CLI 是否打印执行的 hermes-agent-cli.sh 与输出(默认 1;0/false 关闭)
|
||||||
|
|
||||||
|
启动参数(与 df-report ``run.py`` 类似,便于本地一行起服务):
|
||||||
|
python app.py --admin-password '你的密码'
|
||||||
|
python app.py --host 0.0.0.0 --port 8781 --admin-password secret
|
||||||
|
|
||||||
|
依赖补充:交互式频道终端需要 flask-sock、pexpect;仅 macOS/Linux。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
abort,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pexpect
|
||||||
|
except ImportError:
|
||||||
|
pexpect = None # type: ignore
|
||||||
|
|
||||||
|
from flask_sock import Sock
|
||||||
|
|
||||||
|
# hermes-agent-web/ 的父目录应为 hermes-agent-home
|
||||||
|
HERMES_AGENT_HOME = Path(__file__).resolve().parent.parent
|
||||||
|
ENV_FILE = HERMES_AGENT_HOME / ".env"
|
||||||
|
load_dotenv(ENV_FILE)
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_USER = os.environ.get("HERMES_AGENT_WEB_ADMIN_USER", "hermes")
|
||||||
|
# 显式 pbkdf2:部分环境 Python 未编译 hashlib.scrypt,Werkzeug 默认 scrypt 会 AttributeError
|
||||||
|
_PASSWORD_HASH_METHOD = "pbkdf2:sha256"
|
||||||
|
_raw_pw = ""
|
||||||
|
ADMIN_PASSWORD_HASH = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_admin_password_config() -> None:
|
||||||
|
"""从环境变量 HERMES_AGENT_WEB_ADMIN_PASSWORD 刷新哈希(支持启动参数先写入 env 再调用)。"""
|
||||||
|
global _raw_pw, ADMIN_PASSWORD_HASH
|
||||||
|
_raw_pw = os.environ.get("HERMES_AGENT_WEB_ADMIN_PASSWORD", "")
|
||||||
|
if _raw_pw.startswith("pbkdf2:") or _raw_pw.startswith("scrypt:"):
|
||||||
|
ADMIN_PASSWORD_HASH = _raw_pw
|
||||||
|
else:
|
||||||
|
ADMIN_PASSWORD_HASH = (
|
||||||
|
generate_password_hash(_raw_pw, method=_PASSWORD_HASH_METHOD)
|
||||||
|
if _raw_pw
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_reload_admin_password_config()
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get(
|
||||||
|
"HERMES_AGENT_WEB_SECRET_KEY",
|
||||||
|
secrets.token_hex(32),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pty_ws_serializer() -> URLSafeTimedSerializer:
|
||||||
|
return URLSafeTimedSerializer(SECRET_KEY, salt="hermes-agent-web-pty-v1")
|
||||||
|
|
||||||
|
|
||||||
|
def make_pty_token(iid: int, purpose: str) -> str:
|
||||||
|
return _pty_ws_serializer().dumps({"iid": int(iid), "purpose": str(purpose)})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_pty_token(token: str | None, iid: int, purpose: str) -> bool:
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = _pty_ws_serializer().loads(token, max_age=900)
|
||||||
|
return int(data["iid"]) == int(iid) and str(data["purpose"]) == str(purpose)
|
||||||
|
except (BadSignature, SignatureExpired, TypeError, KeyError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def make_guide_pty_token(iid: int) -> str:
|
||||||
|
return make_pty_token(iid, "guide")
|
||||||
|
|
||||||
|
|
||||||
|
def make_docker_pty_token(iid: int) -> str:
|
||||||
|
return make_pty_token(iid, "docker")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_guide_pty_token(token: str | None, iid: int) -> bool:
|
||||||
|
return verify_pty_token(token, iid, "guide")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_docker_pty_token(token: str | None, iid: int) -> bool:
|
||||||
|
return verify_pty_token(token, iid, "docker")
|
||||||
|
|
||||||
|
DATA_ROOT = Path(
|
||||||
|
os.environ.get("HERMES_HOME_DATA_DIR", "")
|
||||||
|
or (HERMES_AGENT_HOME / "hermes-agent-instances")
|
||||||
|
).resolve()
|
||||||
|
CLI_SCRIPT = HERMES_AGENT_HOME / "hermes-agent-cli.sh"
|
||||||
|
# 与 hermes-agent-cli.sh 中标记文件名一致
|
||||||
|
MARK_SETUP_DONE = ".hermes-setup-completed"
|
||||||
|
MARK_SETUP_DECLINED = ".hermes-setup-declined"
|
||||||
|
WEB_META_FILENAME = "web-meta.json"
|
||||||
|
_OWNER_MAX_LEN = 128
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
_CLI_LOG_ENABLED = os.environ.get("HERMES_AGENT_WEB_LOG_CLI", "1").lower() not in (
|
||||||
|
"0",
|
||||||
|
"false",
|
||||||
|
"no",
|
||||||
|
"off",
|
||||||
|
)
|
||||||
|
_CLI_OUT_MAX = 64_000
|
||||||
|
_CLI_TIMEOUT_DEFAULT = int(os.environ.get("HERMES_AGENT_WEB_CLI_TIMEOUT", "300"))
|
||||||
|
_CLI_TIMEOUT_LONG = int(os.environ.get("HERMES_AGENT_WEB_CLI_TIMEOUT_LONG", "3600"))
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_out(s: str) -> str:
|
||||||
|
if len(s) <= _CLI_OUT_MAX:
|
||||||
|
return s
|
||||||
|
return s[:_CLI_OUT_MAX] + f"\n... [stdout/stderr 已截断,原长度 {len(s)} 字符]"
|
||||||
|
|
||||||
|
|
||||||
|
def _data_root() -> Path:
|
||||||
|
DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
return DATA_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(
|
||||||
|
*cli_args: str, timeout: int | None = None
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""所有实例生命周期与 Compose 操作均通过 hermes-agent-home/hermes-agent-cli.sh。"""
|
||||||
|
if not CLI_SCRIPT.is_file():
|
||||||
|
raise RuntimeError(f"找不到 CLI: {CLI_SCRIPT}")
|
||||||
|
if timeout is None:
|
||||||
|
timeout = _CLI_TIMEOUT_DEFAULT
|
||||||
|
env = os.environ.copy()
|
||||||
|
if os.environ.get("HERMES_HOME_DATA_DIR"):
|
||||||
|
env["HERMES_HOME_DATA_DIR"] = os.environ["HERMES_HOME_DATA_DIR"]
|
||||||
|
cmd = ["/usr/bin/env", "bash", str(CLI_SCRIPT), *cli_args]
|
||||||
|
try:
|
||||||
|
cp = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(HERMES_AGENT_HOME),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
out = (e.stdout or "") if isinstance(e.stdout, str) else (e.stdout or b"").decode("utf-8", errors="replace")
|
||||||
|
err = (e.stderr or "") if isinstance(e.stderr, str) else (e.stderr or b"").decode("utf-8", errors="replace")
|
||||||
|
msg = f"命令执行超过 {timeout} 秒已中止。"
|
||||||
|
if "compose-build" in cli_args or "run" in cli_args:
|
||||||
|
msg += " 首次构建 Hermes 镜像较久,请用终端执行 ./hermes-agent-cli.sh compose-build 1,或 Web「构建镜像」。"
|
||||||
|
_LOG.error("hermes-agent-cli 超时: %s", " ".join(shlex.quote(x) for x in cmd))
|
||||||
|
cp = subprocess.CompletedProcess(cmd, 124, out, err + "\n" + msg)
|
||||||
|
if _CLI_LOG_ENABLED:
|
||||||
|
line = " ".join(shlex.quote(x) for x in cmd)
|
||||||
|
_LOG.info("hermes-agent-cli: %s", line)
|
||||||
|
_LOG.info("hermes-agent-cli exit=%s", cp.returncode)
|
||||||
|
out = cp.stdout or ""
|
||||||
|
err = cp.stderr or ""
|
||||||
|
if out.strip():
|
||||||
|
_LOG.info("hermes-agent-cli stdout:\n%s", _truncate_out(out))
|
||||||
|
if err.strip():
|
||||||
|
_LOG.info("hermes-agent-cli stderr:\n%s", _truncate_out(err))
|
||||||
|
if not out.strip() and not err.strip():
|
||||||
|
_LOG.info("hermes-agent-cli (无 stdout/stderr)")
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_compose_check() -> str | None:
|
||||||
|
"""通过脚本的 check 子命令校验 Docker、HERMES_SOURCE 与 compose(含 docker compose config)。"""
|
||||||
|
cp = _run_cli("check", timeout=60)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return (cp.stderr or cp.stdout or "check 失败").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _list_instance_ids_from_dirs_only() -> list[int]:
|
||||||
|
root = _data_root()
|
||||||
|
ids: list[int] = []
|
||||||
|
for p in root.iterdir():
|
||||||
|
if p.is_dir() and re.fullmatch(r"[0-9]+", p.name):
|
||||||
|
ids.append(int(p.name))
|
||||||
|
return sorted(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def list_instance_ids() -> list[int]:
|
||||||
|
"""数据目录序号 ∪ hermes-agent-cli.sh list-ids(Docker 中 hermes-<N>,含已停止容器)。"""
|
||||||
|
cp = _run_cli("list-ids", timeout=60)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return _list_instance_ids_from_dirs_only()
|
||||||
|
seen: set[int] = set()
|
||||||
|
for line in (cp.stdout or "").splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s.isdigit():
|
||||||
|
seen.add(int(s))
|
||||||
|
return sorted(seen)
|
||||||
|
|
||||||
|
|
||||||
|
def next_instance_id() -> int:
|
||||||
|
ids = list_instance_ids()
|
||||||
|
return max(ids) + 1 if ids else 1
|
||||||
|
|
||||||
|
|
||||||
|
def instance_status(instance_id: int) -> dict:
|
||||||
|
"""gateway 容器状态摘要(经 hermes-agent-cli.sh status)。"""
|
||||||
|
try:
|
||||||
|
cp = _run_cli("status", str(instance_id), timeout=120)
|
||||||
|
except Exception as e:
|
||||||
|
return {"running": False, "state": "error", "detail": str(e)}
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return {
|
||||||
|
"running": False,
|
||||||
|
"state": "compose_error",
|
||||||
|
"detail": (cp.stderr or cp.stdout or "").strip(),
|
||||||
|
}
|
||||||
|
raw = (cp.stdout or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {"running": False, "state": "not_created", "detail": ""}
|
||||||
|
row = None
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(obj, list):
|
||||||
|
row = obj[0] if obj else None
|
||||||
|
else:
|
||||||
|
row = obj
|
||||||
|
break
|
||||||
|
if row is None:
|
||||||
|
try:
|
||||||
|
obj = json.loads(raw)
|
||||||
|
if isinstance(obj, list) and obj:
|
||||||
|
row = obj[0]
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
row = obj
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if row is None:
|
||||||
|
return {"running": False, "state": "unknown", "detail": raw[:500]}
|
||||||
|
st = (row.get("State") or row.get("state") or "").lower()
|
||||||
|
running = st == "running"
|
||||||
|
status_line = (row.get("Status") or row.get("status") or "").strip()
|
||||||
|
exit_code = row.get("ExitCode")
|
||||||
|
health = (row.get("Health") or row.get("health") or "").strip()
|
||||||
|
detail_parts: list[str] = []
|
||||||
|
if status_line:
|
||||||
|
detail_parts.append(status_line)
|
||||||
|
if health and health not in status_line.lower():
|
||||||
|
detail_parts.append(f"Health: {health}")
|
||||||
|
if exit_code is not None and str(exit_code) not in ("0", "") and not running:
|
||||||
|
detail_parts.append(f"ExitCode: {exit_code}")
|
||||||
|
return {
|
||||||
|
"running": running,
|
||||||
|
"state": st or "unknown",
|
||||||
|
"name": (row.get("Name") or row.get("Names") or "").strip(),
|
||||||
|
"status_text": status_line,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"health": health,
|
||||||
|
"detail": " · ".join(detail_parts) if detail_parts else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def instance_ports(instance_id: int) -> tuple[int, int]:
|
||||||
|
"""网关 API 端口、Dashboard 端口(与 hermes-agent-cli.sh 一致)。"""
|
||||||
|
return 8641 + instance_id, 9118 + instance_id
|
||||||
|
|
||||||
|
|
||||||
|
def instance_data_dir(instance_id: int) -> Path:
|
||||||
|
return _data_root() / str(instance_id) / "data"
|
||||||
|
|
||||||
|
|
||||||
|
def config_path(instance_id: int) -> Path:
|
||||||
|
return instance_data_dir(instance_id) / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def instance_has_config(instance_id: int) -> bool:
|
||||||
|
data = instance_data_dir(instance_id)
|
||||||
|
return (data / "config.yaml").is_file() or (data / ".env").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_web_meta_path(instance_id: int) -> Path:
|
||||||
|
return _data_root() / str(instance_id) / WEB_META_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def _read_instance_web_meta(instance_id: int) -> dict:
|
||||||
|
path = _instance_web_meta_path(instance_id)
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance_owner(instance_id: int) -> str:
|
||||||
|
raw = _read_instance_web_meta(instance_id).get("owner")
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
return str(raw).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def set_instance_owner(instance_id: int, owner: str) -> None:
|
||||||
|
owner = (owner or "").strip()[:_OWNER_MAX_LEN]
|
||||||
|
path = _instance_web_meta_path(instance_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
meta = _read_instance_web_meta(instance_id)
|
||||||
|
if not owner:
|
||||||
|
meta.pop("owner", None)
|
||||||
|
if not meta:
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
meta["owner"] = owner
|
||||||
|
path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_instance_dirs(instance_id: int) -> Path:
|
||||||
|
data = instance_data_dir(instance_id)
|
||||||
|
data.mkdir(parents=True, exist_ok=True)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_data_dir(instance_id: int) -> Path:
|
||||||
|
return instance_data_dir(instance_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _guide_include_setup_wizard(instance_id: int) -> bool:
|
||||||
|
"""是否在本页自动跑 hermes setup(与脚本标记一致)。"""
|
||||||
|
data = _instance_data_dir(instance_id)
|
||||||
|
if (data / MARK_SETUP_DONE).is_file():
|
||||||
|
return False
|
||||||
|
if (data / MARK_SETUP_DECLINED).is_file():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _guide_shell_command(instance_id: int) -> tuple[str, str]:
|
||||||
|
"""返回 bash -c 的一条命令串,以及发给浏览器的说明文案。"""
|
||||||
|
q_home = shlex.quote(str(HERMES_AGENT_HOME))
|
||||||
|
nid = int(instance_id)
|
||||||
|
if _guide_include_setup_wizard(instance_id):
|
||||||
|
inner = (
|
||||||
|
f"set -euo pipefail; cd {q_home} && "
|
||||||
|
f"./hermes-agent-cli.sh setup {nid} && "
|
||||||
|
f"./hermes-agent-cli.sh run --quick {nid} && "
|
||||||
|
f"exec ./hermes-agent-cli.sh exec {nid}"
|
||||||
|
)
|
||||||
|
hint = (
|
||||||
|
"正在自动执行:hermes setup 向导 → 启动 gateway → 进入容器 shell。"
|
||||||
|
"按向导提示填写 API 密钥与频道配置。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inner = (
|
||||||
|
f"set -euo pipefail; cd {q_home} && "
|
||||||
|
f"./hermes-agent-cli.sh run --quick {nid} && "
|
||||||
|
f"exec ./hermes-agent-cli.sh exec {nid}"
|
||||||
|
)
|
||||||
|
hint = (
|
||||||
|
"将启动 gateway 并进入容器 shell(工作目录 /opt/data)。"
|
||||||
|
"可运行 hermes 子命令或 exit 退出。"
|
||||||
|
)
|
||||||
|
return inner, hint
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_shell_command(instance_id: int) -> tuple[str, str]:
|
||||||
|
"""返回进入实例 gateway 容器 shell 的宿主机命令,以及浏览器提示。"""
|
||||||
|
q_home = shlex.quote(str(HERMES_AGENT_HOME))
|
||||||
|
nid = int(instance_id)
|
||||||
|
inner = (
|
||||||
|
f"set -euo pipefail; cd {q_home} && exec ./hermes-agent-cli.sh exec {nid}"
|
||||||
|
)
|
||||||
|
hint = (
|
||||||
|
"将连接到当前实例运行中的 Hermes gateway 容器(/opt/data)。"
|
||||||
|
"若未启动,请先回列表页点「启动」。"
|
||||||
|
)
|
||||||
|
return inner, hint
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = SECRET_KEY
|
||||||
|
app.permanent_session_lifetime = timedelta(days=7)
|
||||||
|
sock = Sock(app)
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not session.get("logged_in"):
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_csrf():
|
||||||
|
tok = session.get("csrf_token")
|
||||||
|
if not tok:
|
||||||
|
tok = secrets.token_hex(16)
|
||||||
|
session["csrf_token"] = tok
|
||||||
|
return dict(csrf_token=tok)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_csrf():
|
||||||
|
if request.form.get("csrf_token") != session.get("csrf_token"):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_login_next(url: str) -> str:
|
||||||
|
"""登录后仅跳转到可 GET 的页面,避免重放 POST 路由导致 405。"""
|
||||||
|
u = (url or "").strip()
|
||||||
|
if not u.startswith("/") or u.startswith("//"):
|
||||||
|
return url_for("index")
|
||||||
|
# POST-only 或易误用的路径
|
||||||
|
blocked = ("/instance/create",)
|
||||||
|
if u in blocked or "/run" in u or "/stop" in u or "/destroy" in u:
|
||||||
|
return url_for("index")
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if not ADMIN_PASSWORD_HASH and _raw_pw == "":
|
||||||
|
flash(
|
||||||
|
"未设置 HERMES_AGENT_WEB_ADMIN_PASSWORD,请在环境变量、部署配置中设置,"
|
||||||
|
"或使用 python app.py --admin-password 指定后再登录。",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
_check_csrf()
|
||||||
|
user = request.form.get("username", "").strip()
|
||||||
|
pw = request.form.get("password", "")
|
||||||
|
next_url = _safe_login_next(request.form.get("next") or "")
|
||||||
|
if not ADMIN_PASSWORD_HASH:
|
||||||
|
flash("管理员密码未配置。", "error")
|
||||||
|
elif user == DEFAULT_ADMIN_USER and check_password_hash(ADMIN_PASSWORD_HASH, pw):
|
||||||
|
session.clear()
|
||||||
|
session.permanent = True
|
||||||
|
session["logged_in"] = True
|
||||||
|
session["csrf_token"] = secrets.token_hex(16)
|
||||||
|
if (request.form.get("next") or "").strip() in ("/instance/create",):
|
||||||
|
flash("已登录。请再次点击「新建实例」。", "success")
|
||||||
|
return redirect(next_url)
|
||||||
|
flash("用户名或密码错误。", "error")
|
||||||
|
next_raw = (request.args.get("next") or request.form.get("next") or "")[:512]
|
||||||
|
return render_template(
|
||||||
|
"login.html",
|
||||||
|
next_url=next_raw,
|
||||||
|
default_admin_username=DEFAULT_ADMIN_USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
_check_csrf()
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
err_msg = _cli_compose_check()
|
||||||
|
ids = list_instance_ids()
|
||||||
|
rows = []
|
||||||
|
running_n = 0
|
||||||
|
for iid in ids:
|
||||||
|
st = instance_status(iid)
|
||||||
|
if st.get("running"):
|
||||||
|
running_n += 1
|
||||||
|
gpt, dpt = instance_ports(iid)
|
||||||
|
has_cfg = instance_has_config(iid)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": iid,
|
||||||
|
"status": st,
|
||||||
|
"metadata_owner": get_instance_owner(iid),
|
||||||
|
"gateway_port": gpt,
|
||||||
|
"dashboard_port": dpt,
|
||||||
|
"has_config": has_cfg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
rows=rows,
|
||||||
|
total=len(ids),
|
||||||
|
running_n=running_n,
|
||||||
|
data_root=str(DATA_ROOT),
|
||||||
|
compose_error=err_msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/owner", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_set_owner(iid):
|
||||||
|
_check_csrf()
|
||||||
|
raw = (request.form.get("owner") or "").strip()[:_OWNER_MAX_LEN]
|
||||||
|
set_instance_owner(iid, raw)
|
||||||
|
if raw:
|
||||||
|
flash(f"实例 #{iid} 所属人已保存。", "success")
|
||||||
|
else:
|
||||||
|
flash(f"实例 #{iid} 所属人已清空。", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/create", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_create():
|
||||||
|
_check_csrf()
|
||||||
|
nid = next_instance_id()
|
||||||
|
ensure_instance_dirs(nid)
|
||||||
|
cp = _run_cli("bootstrap", str(nid))
|
||||||
|
if cp.returncode != 0:
|
||||||
|
flash(
|
||||||
|
f"创建实例 #{nid} 目录成功,但 bootstrap 失败:\n"
|
||||||
|
f"{cp.stderr or cp.stdout}",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(f"已新建实例 #{nid} 并完成 bootstrap。可点击「启动」。", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/run", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_run(iid):
|
||||||
|
_check_csrf()
|
||||||
|
ensure_instance_dirs(iid)
|
||||||
|
cp = _run_cli("run", "--quick", str(iid), timeout=_CLI_TIMEOUT_LONG)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
err = (cp.stderr or cp.stdout or "").strip()
|
||||||
|
if "尚无镜像" in err or "compose-build" in err or "拉取" in err:
|
||||||
|
flash(
|
||||||
|
f"启动 #{iid} 失败:本地尚无镜像。\n"
|
||||||
|
f"请先点「拉取镜像」(或终端:./hermes-agent-cli.sh compose-build {iid}),完成后再点「启动」。",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
elif cp.returncode == 124:
|
||||||
|
flash(f"启动 #{iid} 超时:\n{err}", "error")
|
||||||
|
else:
|
||||||
|
flash(f"启动 #{iid} 失败:\n{err}", "error")
|
||||||
|
else:
|
||||||
|
st = instance_status(iid)
|
||||||
|
if st.get("running"):
|
||||||
|
hint = st.get("status_text") or "running"
|
||||||
|
gpt, dpt = instance_ports(iid)
|
||||||
|
flash(
|
||||||
|
f"实例 #{iid} 已在运行(Hermes gateway)。Docker 状态:{hint}。"
|
||||||
|
f" API http://127.0.0.1:{gpt} · Dashboard http://127.0.0.1:{dpt}"
|
||||||
|
f" · 容器名一般为 hermes-{iid}-gateway-1。",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
elif st.get("state") == "not_created":
|
||||||
|
flash(
|
||||||
|
f"compose 返回成功,但未查到网关容器(可能仍在创建)。"
|
||||||
|
f" 请在 Docker 中搜索 hermes-{iid} 或执行:"
|
||||||
|
f" docker ps -a --filter name=hermes-{iid}-gateway",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(
|
||||||
|
f"compose 已执行,当前探测状态:{st.get('state')}。"
|
||||||
|
f" {st.get('detail') or ''}"
|
||||||
|
f" 若容器已退出,查看日志:"
|
||||||
|
f" hermes-agent-cli.sh log {iid}",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/compose-build", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_compose_build(iid):
|
||||||
|
_check_csrf()
|
||||||
|
cp = _run_cli("compose-build", str(iid), timeout=_CLI_TIMEOUT_LONG)
|
||||||
|
if cp.returncode == 124:
|
||||||
|
flash(
|
||||||
|
f"拉取 #{iid} 超过 {_CLI_TIMEOUT_LONG} 秒。可能仍在后台进行,"
|
||||||
|
f"请在终端执行:cd {HERMES_AGENT_HOME} && ./hermes-agent-cli.sh compose-build {iid}",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
elif cp.returncode != 0:
|
||||||
|
flash(f"拉取 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||||||
|
else:
|
||||||
|
out = (cp.stdout or "").strip()
|
||||||
|
if "跳过拉取" in out:
|
||||||
|
flash(f"实例 #{iid}:{out.splitlines()[0] if out else '镜像已存在,无需拉取。'} 可直接点「启动」。", "success")
|
||||||
|
else:
|
||||||
|
flash(f"实例 #{iid} 官方镜像已就绪,可点击「启动」。", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/stop", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_stop(iid):
|
||||||
|
_check_csrf()
|
||||||
|
cp = _run_cli("stop", str(iid))
|
||||||
|
if cp.returncode != 0:
|
||||||
|
flash(f"停止 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||||||
|
else:
|
||||||
|
flash(f"实例 #{iid} 网关已停止。", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/restart", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_restart(iid):
|
||||||
|
_check_csrf()
|
||||||
|
cp = _run_cli("restart", str(iid))
|
||||||
|
if cp.returncode != 0:
|
||||||
|
flash(f"重启 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||||||
|
else:
|
||||||
|
flash(f"实例 #{iid} 已重启。", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/destroy", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_destroy(iid):
|
||||||
|
_check_csrf()
|
||||||
|
cp = _run_cli("destroy", str(iid))
|
||||||
|
if cp.returncode != 0:
|
||||||
|
flash(f"销毁 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||||||
|
else:
|
||||||
|
flash(
|
||||||
|
f"实例 #{iid} 已销毁:compose 项目已 down,宿主机上该实例目录(含 config/workspace)已删除。",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/config", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def instance_config(iid):
|
||||||
|
path = config_path(iid)
|
||||||
|
if request.method == "POST":
|
||||||
|
_check_csrf()
|
||||||
|
text = request.form.get("config_body", "")
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
|
||||||
|
parsed = yaml.safe_load(text)
|
||||||
|
if parsed is not None and not isinstance(parsed, dict):
|
||||||
|
raise ValueError("根节点须为 YAML 映射(mapping)")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"YAML 无效:{e}", "error")
|
||||||
|
return render_template("config_edit.html", iid=iid, text=text)
|
||||||
|
backup = path.with_suffix(".yaml.bak")
|
||||||
|
if path.is_file():
|
||||||
|
shutil.copy2(path, backup)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
||||||
|
flash(
|
||||||
|
"已保存 config.yaml(旧文件已备份为 config.yaml.bak)。修改后请在列表页重启 gateway。",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("instance_config", iid=iid))
|
||||||
|
if path.is_file():
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
else:
|
||||||
|
text = "# Hermes Agent config.yaml\nmodel:\n provider: anthropic\n"
|
||||||
|
flash("该实例尚无 config.yaml,编辑保存后将创建。", "warning")
|
||||||
|
return render_template("config_edit.html", iid=iid, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_pty_bridge(ws, iid: int, title: str, command_factory) -> None:
|
||||||
|
"""通用 WebSocket ↔ 伪终端桥接。"""
|
||||||
|
assert pexpect is not None
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault("TERM", "xterm-256color")
|
||||||
|
env.setdefault("COLORTERM", "truecolor")
|
||||||
|
env.setdefault("BASH_SILENCE_DEPRECATION_WARNING", "1")
|
||||||
|
bash = shutil.which("bash") or "/bin/bash"
|
||||||
|
inner, hint = command_factory(iid)
|
||||||
|
child = pexpect.spawn(
|
||||||
|
bash,
|
||||||
|
["--noprofile", "--norc", "-c", inner],
|
||||||
|
cwd=str(HERMES_AGENT_HOME),
|
||||||
|
env=env,
|
||||||
|
echo=False,
|
||||||
|
encoding="utf-8",
|
||||||
|
codec_errors="replace",
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
child.setwinsize(24, 100)
|
||||||
|
|
||||||
|
banner = (
|
||||||
|
f"\r\n\x1b[36m══ {title}(实例 {iid})══\x1b[0m\r\n"
|
||||||
|
f"\x1b[90m{hint}\x1b[0m\r\n\r\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ws.send(banner)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def forward_pty() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = child.read_nonblocking(65536, timeout=0.25)
|
||||||
|
if chunk:
|
||||||
|
ws.send(chunk)
|
||||||
|
except pexpect.TIMEOUT:
|
||||||
|
continue
|
||||||
|
except (pexpect.EOF, OSError):
|
||||||
|
try:
|
||||||
|
ws.send("\r\n\x1b[33m( shell 已退出,可关闭本页 )\x1b[0m\r\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
threading.Thread(target=forward_pty, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = ws.receive()
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
if isinstance(msg, bytes):
|
||||||
|
msg = msg.decode("utf-8", errors="replace")
|
||||||
|
if len(msg) > 2 and msg[0] == "{" and msg[-1] == "}":
|
||||||
|
try:
|
||||||
|
o = json.loads(msg)
|
||||||
|
if o.get("t") == "resize":
|
||||||
|
rows = max(5, min(int(o["rows"]), 200))
|
||||||
|
cols = max(20, min(int(o["cols"]), 500))
|
||||||
|
child.setwinsize(rows, cols)
|
||||||
|
continue
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
child.send(msg)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
child.close(force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@sock.route("/ws/instance/<int:iid>/pty")
|
||||||
|
def guide_pty_ws(ws, iid: int):
|
||||||
|
"""引导终端 WebSocket(签名 token,避免 WS 握手丢失 session)。"""
|
||||||
|
tok = request.args.get("token", type=str)
|
||||||
|
if not verify_guide_pty_token(tok, iid):
|
||||||
|
try:
|
||||||
|
ws.send(
|
||||||
|
"\r\n\x1b[31m[未授权:请从首页「Hermes 引导」重新进入"
|
||||||
|
"(链接 15 分钟内有效)]\x1b[0m\r\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
ws.send("\r\n[网页终端仅支持 macOS / Linux]\r\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if pexpect is None:
|
||||||
|
try:
|
||||||
|
ws.send("\r\n[缺少依赖: pip install pexpect]\r\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_command_pty_bridge(ws, iid, "Hermes 引导", _guide_shell_command)
|
||||||
|
|
||||||
|
|
||||||
|
@sock.route("/ws/instance/<int:iid>/docker-pty")
|
||||||
|
def docker_pty_ws(ws, iid: int):
|
||||||
|
"""Docker 容器终端 WebSocket(签名 token)。"""
|
||||||
|
tok = request.args.get("token", type=str)
|
||||||
|
if not verify_docker_pty_token(tok, iid):
|
||||||
|
try:
|
||||||
|
ws.send(
|
||||||
|
"\r\n\x1b[31m[未授权:请从首页「终端」重新进入"
|
||||||
|
"(链接 15 分钟内有效)]\x1b[0m\r\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
ws.send("\r\n[网页终端仅支持 macOS / Linux]\r\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if pexpect is None:
|
||||||
|
try:
|
||||||
|
ws.send("\r\n[缺少依赖: pip install pexpect]\r\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_command_pty_bridge(ws, iid, "Docker 终端", _docker_shell_command)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/guide")
|
||||||
|
@login_required
|
||||||
|
def instance_guide(iid):
|
||||||
|
"""引导终端:自动执行 setup(如需)→ 启动网关 → onboard,用户只应答交互提示。"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
flash("网页引导终端仅支持 macOS / Linux。", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
if pexpect is None:
|
||||||
|
flash("请安装依赖:pip install -r requirements.txt(含 pexpect、flask-sock)", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
return render_template(
|
||||||
|
"guide_terminal.html",
|
||||||
|
iid=iid,
|
||||||
|
ws_token=make_guide_pty_token(iid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/docker")
|
||||||
|
@login_required
|
||||||
|
def instance_docker_terminal(iid):
|
||||||
|
"""运行中实例的 Docker 容器终端。"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
flash("网页终端仅支持 macOS / Linux。", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
if pexpect is None:
|
||||||
|
flash("请安装依赖:pip install -r requirements.txt(含 pexpect、flask-sock)", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
return render_template(
|
||||||
|
"docker_terminal.html",
|
||||||
|
iid=iid,
|
||||||
|
ws_token=make_docker_pty_token(iid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/instance/<int:iid>/channels")
|
||||||
|
@app.route("/instance/<int:iid>/channels/term")
|
||||||
|
@login_required
|
||||||
|
def legacy_channels_redirect(iid):
|
||||||
|
return redirect(url_for("instance_guide", iid=iid))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
ap = argparse.ArgumentParser(description="Hermes Agent Web 管理端")
|
||||||
|
ap.add_argument("--host", default=None, help="监听地址(默认同 HERMES_AGENT_WEB_HOST,127.0.0.1)")
|
||||||
|
ap.add_argument("--port", type=int, default=None, help="端口(默认同 HERMES_AGENT_WEB_PORT,8781)")
|
||||||
|
ap.add_argument(
|
||||||
|
"--admin-password",
|
||||||
|
default="",
|
||||||
|
help="管理员登录密码,写入 HERMES_AGENT_WEB_ADMIN_PASSWORD(与 df-report --admin-password 类似)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.admin_password:
|
||||||
|
os.environ["HERMES_AGENT_WEB_ADMIN_PASSWORD"] = args.admin_password
|
||||||
|
_reload_admin_password_config()
|
||||||
|
host = (
|
||||||
|
args.host
|
||||||
|
if args.host is not None
|
||||||
|
else os.environ.get("HERMES_AGENT_WEB_HOST", "127.0.0.1")
|
||||||
|
)
|
||||||
|
port = (
|
||||||
|
int(args.port)
|
||||||
|
if args.port is not None
|
||||||
|
else int(os.environ.get("HERMES_AGENT_WEB_PORT", "8781"))
|
||||||
|
)
|
||||||
|
if not ADMIN_PASSWORD_HASH and _raw_pw == "":
|
||||||
|
print(
|
||||||
|
"警告: 未设置 HERMES_AGENT_WEB_ADMIN_PASSWORD,登录将不可用。"
|
||||||
|
" 可: HERMES_AGENT_WEB_ADMIN_PASSWORD=你的密码 python app.py"
|
||||||
|
" 或: python app.py --admin-password 你的密码",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
print(f"Hermes Agent Web: http://{host}:{port}", flush=True)
|
||||||
|
app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
flask-sock>=0.7.0
|
||||||
|
pexpect>=4.9.0
|
||||||
|
pyyaml>=6.0.0
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Hermes Agent Web{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--surface: #1a2332;
|
||||||
|
--border: #2d3a4d;
|
||||||
|
--text: #e7edf4;
|
||||||
|
--muted: #8b9cb3;
|
||||||
|
--accent: #3d9eff;
|
||||||
|
--danger: #f0696a;
|
||||||
|
--ok: #4cc38a;
|
||||||
|
--warn: #e6b35a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
header h1 { font-size: 1.125rem; margin: 0; font-weight: 600; }
|
||||||
|
main { max-width: 960px; margin: 0 auto; padding: 1.25rem; }
|
||||||
|
.flash {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.flash.success { border-color: #2a5c42; background: #15251c; color: #b8e8c8; }
|
||||||
|
.flash.error { border-color: #6b3030; background: #251515; color: #f5c0c0; }
|
||||||
|
.flash.warning { border-color: #6b5a30; background: #252015; color: #f0dfb0; }
|
||||||
|
.muted { color: var(--muted); font-size: 0.875rem; }
|
||||||
|
button, .btn {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
}
|
||||||
|
button.primary { background: #1a4d7a; border-color: #2563a8; }
|
||||||
|
button.danger { background: #4a2525; border-color: #8b4040; color: #fecaca; }
|
||||||
|
button.linkish {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
th { color: var(--muted); font-weight: 500; }
|
||||||
|
code { font-size: 0.85em; background: #0c1017; padding: 0.15rem 0.35rem; border-radius: 4px; }
|
||||||
|
.row-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||||
|
textarea.code {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: #0c1017;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge.ok { background: #1a3d2a; color: #8fe3b0; }
|
||||||
|
.badge.off { background: #2a2a2a; color: #aaa; }
|
||||||
|
.badge.err { background: #3d1a1a; color: #f0a0a0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><a href="{{ url_for('index') }}" style="color:inherit;text-decoration:none;">Hermes Agent Web</a></h1>
|
||||||
|
{% if session.get('logged_in') %}
|
||||||
|
<form method="post" action="{{ url_for('logout') }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="linkish">退出</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, msg in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}实例 {{ iid }} 配置 · Hermes Agent Web{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="{{ url_for('index') }}">← 返回列表</a></p>
|
||||||
|
<h2 style="margin-top:0;">实例 <code>{{ iid }}</code> · config.yaml</h2>
|
||||||
|
<p class="muted">路径:<code>data/config.yaml</code>。保存前会校验 YAML。若文件已存在,将备份为 <code>config.yaml.bak</code>。修改后请在列表页「重启」gateway。</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<textarea class="code" name="config_body" spellcheck="false">{{ text }}</textarea>
|
||||||
|
<p>
|
||||||
|
<button type="submit" class="primary">保存</button>
|
||||||
|
<a href="{{ url_for('index') }}">取消</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}实例 {{ iid }} · Docker 终端 · Hermes Agent Web{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="{{ url_for('index') }}">← 返回实例列表</a></p>
|
||||||
|
<h2 style="margin-top:0;">实例 <code>{{ iid }}</code> · Docker 终端</h2>
|
||||||
|
<p class="muted">打开本页后会连接到该实例<strong>运行中的</strong> Hermes <code>gateway</code> 容器 shell(/opt/data)。
|
||||||
|
若容器尚未启动,请先返回列表页点「启动」;退出终端可输入 <code>exit</code>。</p>
|
||||||
|
<div style="margin:1rem 0 1.1rem;padding:0.9rem 1rem;border:1px solid var(--border);border-radius:10px;background:#111723;">
|
||||||
|
<div style="font-size:0.95rem;font-weight:600;margin-bottom:0.65rem;">常用指令</div>
|
||||||
|
<div style="display:grid;gap:0.65rem;">
|
||||||
|
<div>
|
||||||
|
<div class="muted" style="margin-bottom:0.25rem;">切换到 Codex Provider 登录</div>
|
||||||
|
<code style="display:block;padding:0.65rem 0.8rem;white-space:pre-wrap;">hermes config</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="term-wrap" style="height:min(75vh,620px);border:1px solid var(--border);border-radius:8px;padding:2px;background:#0c1017;"></div>
|
||||||
|
<p class="muted" style="margin-top:0.75rem;">关闭本页请先退出容器 shell。需联网加载 xterm(CDN)。</p>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" crossorigin="anonymous" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var wrap = document.getElementById('term-wrap');
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
|
||||||
|
theme: { background: '#0c1017', foreground: '#e7edf4' }
|
||||||
|
});
|
||||||
|
var FitCtor = (typeof FitAddon !== 'undefined' && FitAddon.FitAddon) ? FitAddon.FitAddon : FitAddon;
|
||||||
|
var fit = new FitCtor();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(wrap);
|
||||||
|
fit.fit();
|
||||||
|
term.writeln('\x1b[90m正在连接 Docker 终端…\x1b[0m');
|
||||||
|
|
||||||
|
var proto = (location.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||||
|
var tok = {{ ws_token | tojson }};
|
||||||
|
var ws = new WebSocket(proto + '//' + location.host + '/ws/instance/{{ iid }}/docker-pty?token=' + encodeURIComponent(tok));
|
||||||
|
ws.onopen = function () {
|
||||||
|
term.reset();
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
};
|
||||||
|
ws.onmessage = function (ev) {
|
||||||
|
var d = ev.data;
|
||||||
|
if (typeof d === 'string') term.write(d);
|
||||||
|
else term.write(new Uint8Array(d));
|
||||||
|
};
|
||||||
|
ws.onerror = function () {
|
||||||
|
term.write('\r\n\x1b[31m[WebSocket 错误]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
ws.onclose = function (ev) {
|
||||||
|
term.write('\r\n\x1b[33m[连接已关闭' + (ev && ev.code ? ' code=' + ev.code : '') + ']\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
term.onData(function (data) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResize() {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
ws.send(JSON.stringify({ t: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
new ResizeObserver(sendResize).observe(wrap);
|
||||||
|
setTimeout(sendResize, 100);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}实例 {{ iid }} · Hermes 引导 · Hermes Agent Web{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="{{ url_for('index') }}">← 返回实例列表</a></p>
|
||||||
|
<h2 style="margin-top:0;">实例 <code>{{ iid }}</code> · 引导终端</h2>
|
||||||
|
<p class="muted">打开本页后会<strong>自动</strong>在后台执行官方引导链路(未完成 setup 时先跑向导,再启动网关,再进入 <code>onboard</code>)。
|
||||||
|
你一般<strong>不用输入命令</strong>,只需在终端里按程序提问操作;需要改配置可用「编辑配置」。</p>
|
||||||
|
<div id="term-wrap" style="height:min(75vh,620px);border:1px solid var(--border);border-radius:8px;padding:2px;background:#0c1017;"></div>
|
||||||
|
<p class="muted" style="margin-top:0.75rem;">关闭本页请先 <kbd>Ctrl+C</kbd> 结束当前命令。需联网加载 xterm(CDN)。</p>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" crossorigin="anonymous" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var wrap = document.getElementById('term-wrap');
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
|
||||||
|
theme: { background: '#0c1017', foreground: '#e7edf4' }
|
||||||
|
});
|
||||||
|
var FitCtor = (typeof FitAddon !== 'undefined' && FitAddon.FitAddon) ? FitAddon.FitAddon : FitAddon;
|
||||||
|
var fit = new FitCtor();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(wrap);
|
||||||
|
fit.fit();
|
||||||
|
term.writeln('\x1b[90m正在连接引导终端…\x1b[0m');
|
||||||
|
|
||||||
|
var proto = (location.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||||
|
var tok = {{ ws_token | tojson }};
|
||||||
|
var ws = new WebSocket(proto + '//' + location.host + '/ws/instance/{{ iid }}/pty?token=' + encodeURIComponent(tok));
|
||||||
|
ws.onopen = function () {
|
||||||
|
term.reset();
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
};
|
||||||
|
ws.onmessage = function (ev) {
|
||||||
|
var d = ev.data;
|
||||||
|
if (typeof d === 'string') term.write(d);
|
||||||
|
else term.write(new Uint8Array(d));
|
||||||
|
};
|
||||||
|
ws.onerror = function () {
|
||||||
|
term.write('\r\n\x1b[31m[WebSocket 错误]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
ws.onclose = function (ev) {
|
||||||
|
term.write('\r\n\x1b[33m[连接已关闭' + (ev && ev.code ? ' code=' + ev.code : '') + ']\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
term.onData(function (data) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResize() {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
ws.send(JSON.stringify({ t: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
new ResizeObserver(sendResize).observe(wrap);
|
||||||
|
setTimeout(sendResize, 100);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}实例 · Hermes Agent Web{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin-top:0;">实例</h2>
|
||||||
|
<p class="muted">
|
||||||
|
数据目录:<code>{{ data_root }}</code> ·
|
||||||
|
合计 <strong>{{ total }}</strong> 个实例 ·
|
||||||
|
网关运行中 <strong>{{ running_n }}</strong> 个
|
||||||
|
· 列表「所属人」记在每台目录下的 <code>web-meta.json</code>(与 <code>data/config.yaml</code> 分离)
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="max-width:52rem;">
|
||||||
|
首次配置请点 <strong>「Hermes 引导」</strong>(<code>setup</code> → 启动 gateway → 容器 shell)。
|
||||||
|
Docker 项目名 <code>hermes-<序号></code>,服务 <code>gateway</code>(NousResearch/hermes-agent)。
|
||||||
|
</p>
|
||||||
|
{% if compose_error %}
|
||||||
|
<div class="flash error">Compose 配置错误:{{ compose_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('instance_create') }}" style="margin:1rem 0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="primary">新建实例</button>
|
||||||
|
<span class="muted">将分配新序号、创建目录并执行 <code>bootstrap</code>;之后可点「启动」拉起网关。</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if not rows %}
|
||||||
|
<p class="muted">暂无实例。点击「新建实例」开始。</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>所属人</th>
|
||||||
|
<th>端口</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ r.id }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if r.status.running %}
|
||||||
|
<span class="badge ok">运行中</span>
|
||||||
|
{% elif r.status.state == 'not_created' %}
|
||||||
|
<span class="badge off">未创建容器</span>
|
||||||
|
{% elif r.status.state in ('exited', 'dead') %}
|
||||||
|
<span class="badge off">已停止</span> <span class="muted">{{ r.status.state }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge err">{{ r.status.state }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if r.status.name %}
|
||||||
|
<div class="muted" style="margin-top:0.35rem;"><code>{{ r.status.name }}</code></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if r.status.detail %}
|
||||||
|
<div class="muted" style="margin-top:0.25rem;">{{ r.status.detail }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not r.has_config %}
|
||||||
|
<div class="muted" style="margin-top:0.25rem;">未配置(无 config.yaml / .env)</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="min-width:12rem;vertical-align:middle;">
|
||||||
|
<form method="post" action="{{ url_for('instance_set_owner', iid=r.id) }}" style="margin:0;display:flex;flex-direction:column;gap:0.45rem;align-items:stretch;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="text" name="owner" value="{{ r.metadata_owner }}" placeholder="未设置" maxlength="128" autocomplete="off" style="width:50%;box-sizing:border-box;padding:0.4rem 0.5rem;border-radius:6px;border:1px solid var(--border);background:#0c1017;color:var(--text);font:inherit;">
|
||||||
|
<button type="submit" class="btn" style="align-self:flex-start;">保存</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="muted">网关</span> <code>{{ r.gateway_port }}</code>
|
||||||
|
<br>
|
||||||
|
<span class="muted">Dashboard</span> <code>{{ r.dashboard_port }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form method="post" action="{{ url_for('instance_compose_build', iid=r.id) }}" style="margin:0;display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" title="从 Docker Hub 拉取官方镜像 nousresearch/hermes-agent">拉取镜像</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('instance_run', iid=r.id) }}" style="margin:0;display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="primary">启动</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ url_for('instance_guide', iid=r.id) }}" class="btn primary" style="display:inline-block;padding:0.35rem 0.65rem;text-decoration:none;background:#1a4d7a;border:1px solid #2563a8;border-radius:6px;color:#e7edf4;font:inherit;">Hermes 引导</a>
|
||||||
|
<a href="{{ url_for('instance_docker_terminal', iid=r.id) }}" class="btn" style="display:inline-block;padding:0.35rem 0.65rem;text-decoration:none;background:#111827;border:1px solid #374151;border-radius:6px;color:#e7edf4;font:inherit;">终端</a>
|
||||||
|
<form method="post" action="{{ url_for('instance_stop', iid=r.id) }}" style="margin:0;display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit">停止</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('instance_restart', iid=r.id) }}" style="margin:0;display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit">重启</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('instance_destroy', iid=r.id) }}" style="margin:0;display:inline;" onsubmit="return confirm('确定销毁该实例?将执行 compose down,并永久删除宿主机上该序号的配置与工作区目录(不可恢复)。');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="danger">销毁</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ url_for('instance_config', iid=r.id) }}">编辑配置</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>登录 · Hermes Agent Web</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--surface: #1a2332;
|
||||||
|
--border: #2d3a4f;
|
||||||
|
--text: #e7ecf3;
|
||||||
|
--muted: #8b9cb3;
|
||||||
|
--accent: #5b9cf6;
|
||||||
|
--accent-dim: #3d7cd6;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: "SF Pro Text", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
background: radial-gradient(1200px 800px at 10% -10%, #1a2840 0%, var(--bg) 55%);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 24px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #0d1218;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
box-shadow: 0 0 0 3px rgba(91, 156, 246, 0.2);
|
||||||
|
}
|
||||||
|
.field { margin-top: 16px; }
|
||||||
|
.field:first-of-type { margin-top: 0; }
|
||||||
|
button {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dim) 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(1.06); }
|
||||||
|
.err {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(240, 113, 120, 0.12);
|
||||||
|
border: 1px solid rgba(240, 113, 120, 0.35);
|
||||||
|
color: #ffb3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>管理员登录</h1>
|
||||||
|
<p class="sub">Hermes Agent Web · 请输入用户名和密码登录</p>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
{% if category == 'error' %}
|
||||||
|
<div class="err">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
<form method="post" action="{{ url_for('login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input id="username" name="username" type="text" value="{{ default_admin_username }}" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user