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:
carl
2026-05-17 13:56:04 +08:00
co-authored by Cursor
commit 5341a7617b
16 changed files with 2357 additions and 0 deletions
+14
View File
@@ -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
View File
@@ -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]
+41
View File
@@ -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
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# 可选:HERMES_USE_LOCAL_BUILD=1 时叠加,从 HERMES_SOURCE 源码构建
services:
gateway:
build:
context: ${HERMES_BUILD_CONTEXT}
dockerfile: Dockerfile
+19
View File
@@ -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"]
+490
View File
@@ -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
+63
View File
@@ -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` 核对。
+927
View File
@@ -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()
+5
View File
@@ -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
+121
View File
@@ -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 %}
+106
View File
@@ -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-&lt;序号&gt;</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 %}
+125
View File
@@ -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>