From 5341a7617b3eb7f4cf1dde4839f5a144bdabef85 Mon Sep 17 00:00:00 2001 From: carl Date: Sun, 17 May 2026 13:56:04 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Hermes=20Agent=20=E5=A4=9A?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E7=AE=A1=E7=90=86=E4=B8=8E=20Web=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于官方 nousresearch/hermes-agent 镜像的 CLI、Docker Compose 与 Web 管理界面,支持多实例部署;敏感配置与实例数据由 .gitignore 排除。 Co-authored-by: Cursor --- .env.example | 14 + .gitignore | 184 ++++ README.md | 41 + allowall.sh | 96 ++ docker-compose.instance.build.yml | 6 + docker-compose.instance.yml | 19 + hermes-agent-cli.sh | 490 +++++++++ hermes-agent-web/README.md | 63 ++ hermes-agent-web/app.py | 927 ++++++++++++++++++ hermes-agent-web/requirements.txt | 5 + hermes-agent-web/templates/base.html | 121 +++ hermes-agent-web/templates/config_edit.html | 15 + .../templates/docker_terminal.html | 77 ++ .../templates/guide_terminal.html | 68 ++ hermes-agent-web/templates/index.html | 106 ++ hermes-agent-web/templates/login.html | 125 +++ 16 files changed, 2357 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 allowall.sh create mode 100644 docker-compose.instance.build.yml create mode 100644 docker-compose.instance.yml create mode 100755 hermes-agent-cli.sh create mode 100644 hermes-agent-web/README.md create mode 100644 hermes-agent-web/app.py create mode 100644 hermes-agent-web/requirements.txt create mode 100644 hermes-agent-web/templates/base.html create mode 100644 hermes-agent-web/templates/config_edit.html create mode 100644 hermes-agent-web/templates/docker_terminal.html create mode 100644 hermes-agent-web/templates/guide_terminal.html create mode 100644 hermes-agent-web/templates/index.html create mode 100644 hermes-agent-web/templates/login.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48a9660 --- /dev/null +++ b/.env.example @@ -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=你的强密码 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e373c58 --- /dev/null +++ b/.gitignore @@ -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] diff --git a/README.md b/README.md new file mode 100644 index 0000000..09fcc52 --- /dev/null +++ b/README.md @@ -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-` | `hermes-` | + +## 快速开始 + +```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//config/` 与 Hermes 的 `data/` 布局不兼容。 + +文档:https://hermes-agent.nousresearch.com/docs/user-guide/docker diff --git a/allowall.sh b/allowall.sh new file mode 100644 index 0000000..d3ca34a --- /dev/null +++ b/allowall.sh @@ -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 \ No newline at end of file diff --git a/docker-compose.instance.build.yml b/docker-compose.instance.build.yml new file mode 100644 index 0000000..033af21 --- /dev/null +++ b/docker-compose.instance.build.yml @@ -0,0 +1,6 @@ +# 可选:HERMES_USE_LOCAL_BUILD=1 时叠加,从 HERMES_SOURCE 源码构建 +services: + gateway: + build: + context: ${HERMES_BUILD_CONTEXT} + dockerfile: Dockerfile diff --git a/docker-compose.instance.yml b/docker-compose.instance.yml new file mode 100644 index 0000000..0a41295 --- /dev/null +++ b/docker-compose.instance.yml @@ -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"] diff --git a/hermes-agent-cli.sh b/hermes-agent-cli.sh new file mode 100755 index 0000000..c22d512 --- /dev/null +++ b/hermes-agent-cli.sh @@ -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-) + 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 diff --git a/hermes-agent-web/README.md b/hermes-agent-web/README.md new file mode 100644 index 0000000..b98f25f --- /dev/null +++ b/hermes-agent-web/README.md @@ -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-` 项目 / `hermes--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//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` 核对。 diff --git a/hermes-agent-web/app.py b/hermes-agent-web/app.py new file mode 100644 index 0000000..a22cf6f --- /dev/null +++ b/hermes-agent-web/app.py @@ -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-,含已停止容器)。""" + 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//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//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//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//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//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//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//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//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//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//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//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//channels") +@app.route("/instance//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() diff --git a/hermes-agent-web/requirements.txt b/hermes-agent-web/requirements.txt new file mode 100644 index 0000000..2f0034f --- /dev/null +++ b/hermes-agent-web/requirements.txt @@ -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 diff --git a/hermes-agent-web/templates/base.html b/hermes-agent-web/templates/base.html new file mode 100644 index 0000000..c5aabff --- /dev/null +++ b/hermes-agent-web/templates/base.html @@ -0,0 +1,121 @@ + + + + + + {% block title %}Hermes Agent Web{% endblock %} + + + +
+

Hermes Agent Web

+ {% if session.get('logged_in') %} +
+ + +
+ {% endif %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block body %}{% endblock %} +
+ + diff --git a/hermes-agent-web/templates/config_edit.html b/hermes-agent-web/templates/config_edit.html new file mode 100644 index 0000000..23753bc --- /dev/null +++ b/hermes-agent-web/templates/config_edit.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}实例 {{ iid }} 配置 · Hermes Agent Web{% endblock %} +{% block body %} +

← 返回列表

+

实例 {{ iid }} · config.yaml

+

路径:data/config.yaml。保存前会校验 YAML。若文件已存在,将备份为 config.yaml.bak。修改后请在列表页「重启」gateway。

+
+ + +

+ + 取消 +

+
+{% endblock %} diff --git a/hermes-agent-web/templates/docker_terminal.html b/hermes-agent-web/templates/docker_terminal.html new file mode 100644 index 0000000..9cacca4 --- /dev/null +++ b/hermes-agent-web/templates/docker_terminal.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}实例 {{ iid }} · Docker 终端 · Hermes Agent Web{% endblock %} +{% block body %} +

← 返回实例列表

+

实例 {{ iid }} · Docker 终端

+

打开本页后会连接到该实例运行中的 Hermes gateway 容器 shell(/opt/data)。 + 若容器尚未启动,请先返回列表页点「启动」;退出终端可输入 exit。

+
+
常用指令
+
+
+
切换到 Codex Provider 登录
+ hermes config +
+
+
+
+

关闭本页请先退出容器 shell。需联网加载 xterm(CDN)。

+ + + + + +{% endblock %} diff --git a/hermes-agent-web/templates/guide_terminal.html b/hermes-agent-web/templates/guide_terminal.html new file mode 100644 index 0000000..c420594 --- /dev/null +++ b/hermes-agent-web/templates/guide_terminal.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}实例 {{ iid }} · Hermes 引导 · Hermes Agent Web{% endblock %} +{% block body %} +

← 返回实例列表

+

实例 {{ iid }} · 引导终端

+

打开本页后会自动在后台执行官方引导链路(未完成 setup 时先跑向导,再启动网关,再进入 onboard)。 + 你一般不用输入命令,只需在终端里按程序提问操作;需要改配置可用「编辑配置」。

+
+

关闭本页请先 Ctrl+C 结束当前命令。需联网加载 xterm(CDN)。

+ + + + + +{% endblock %} diff --git a/hermes-agent-web/templates/index.html b/hermes-agent-web/templates/index.html new file mode 100644 index 0000000..e226026 --- /dev/null +++ b/hermes-agent-web/templates/index.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% block title %}实例 · Hermes Agent Web{% endblock %} +{% block body %} +

实例

+

+ 数据目录:{{ data_root }} · + 合计 {{ total }} 个实例 · + 网关运行中 {{ running_n }} 个 + · 列表「所属人」记在每台目录下的 web-meta.json(与 data/config.yaml 分离) +

+

+ 首次配置请点 「Hermes 引导」(setup → 启动 gateway → 容器 shell)。 + Docker 项目名 hermes-<序号>,服务 gateway(NousResearch/hermes-agent)。 +

+{% if compose_error %} +
Compose 配置错误:{{ compose_error }}
+{% endif %} + +
+ + + 将分配新序号、创建目录并执行 bootstrap;之后可点「启动」拉起网关。 +
+ +{% if not rows %} +

暂无实例。点击「新建实例」开始。

+{% else %} + + + + + + + + + + + + {% for r in rows %} + + + + + + + + {% endfor %} + +
#状态所属人端口操作
{{ r.id }} + {% if r.status.running %} + 运行中 + {% elif r.status.state == 'not_created' %} + 未创建容器 + {% elif r.status.state in ('exited', 'dead') %} + 已停止 {{ r.status.state }} + {% else %} + {{ r.status.state }} + {% endif %} + {% if r.status.name %} +
{{ r.status.name }}
+ {% endif %} + {% if r.status.detail %} +
{{ r.status.detail }}
+ {% endif %} + {% if not r.has_config %} +
未配置(无 config.yaml / .env)
+ {% endif %} +
+
+ + + +
+
+ 网关 {{ r.gateway_port }} +
+ Dashboard {{ r.dashboard_port }} +
+
+
+ + +
+
+ + +
+ Hermes 引导 + 终端 +
+ + +
+
+ + +
+
+ + +
+ 编辑配置 +
+
+{% endif %} +{% endblock %} diff --git a/hermes-agent-web/templates/login.html b/hermes-agent-web/templates/login.html new file mode 100644 index 0000000..be7a964 --- /dev/null +++ b/hermes-agent-web/templates/login.html @@ -0,0 +1,125 @@ + + + + + + 登录 · Hermes Agent Web + + + +
+

管理员登录

+

Hermes Agent Web · 请输入用户名和密码登录

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + {% if category == 'error' %} +
{{ message }}
+ {% endif %} + {% endfor %} + {% endwith %} +
+ + +
+ + +
+
+ + +
+ +
+
+ +