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