feat: Initial implementation of hermes-service with multi-user support

- Complete REST API with session management
- Dynamic user creation with isolated environments
- Multi-user isolation using /d/hermesai/users/{user_id}/.hermes structure
- Full command execution capabilities via Hermes CLI
- Health check and status endpoints
- Follows module development specifications
This commit is contained in:
yumoqing 2026-04-21 13:20:10 +08:00
commit 7d70f362b2
10 changed files with 572 additions and 0 deletions

24
README.md Normal file
View File

@ -0,0 +1,24 @@
# Hermes Service Web Application
## Overview
Hermes Service is a web application that provides API access to Hermes Agent functionality. It runs in the Hermes Agent environment and exposes CLI capabilities through REST/WebSocket APIs.
## Features
- Execute Hermes CLI commands via API
- Manage multiple Hermes service instances
- User and organization-based access control (using rbac module)
- Real-time communication via WebSocket
- Configuration management
## Integration
This module integrates with:
- **rbac**: For user and permission management
- **appbase**: For system parameter management
- **Hermes Agent**: Core functionality execution
## Directory Structure
- `hermes-service/`: Python package with core logic
- `wwwroot/`: Frontend components (bricks-framework .ui files)
- `models/`: Database table definitions
- `json/`: CRUD operation definitions
- `init/`: Initialization data

27
build.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/bash
# hermes-service build script
MODULE_NAME="hermes-service"
MODULE_PATH="/d/hermesai/repos/hermes-service"
echo "Building $MODULE_NAME module..."
# Create symbolic link to main wwwroot
if [ -d "$MODULE_PATH/wwwroot" ]; then
ln -sf "$MODULE_PATH/wwwroot" "/d/hermesai/.hermes/hermes-agent/wwwroot/$MODULE_NAME"
echo "Created wwwroot symlink for $MODULE_NAME"
fi
# Generate database DDL if models exist
if [ -d "$MODULE_PATH/models" ] && [ "$(ls -A $MODULE_PATH/models)" ]; then
echo "Generating database DDL..."
# This will be handled by the main build process
fi
# Generate CRUD UI if json exists
if [ -d "$MODULE_PATH/json" ] && [ "$(ls -A $MODULE_PATH/json)" ]; then
echo "Generating CRUD UI..."
# This will be handled by the main build process
fi
echo "$MODULE_NAME build completed successfully!"

View File

79
hermes-service/init.py Normal file
View File

@ -0,0 +1,79 @@
from ahserver.serverenv import ServerEnv
from appPublic.worker import awaitify
import subprocess
import json
import os
def load_hermes_service():
"""Load hermes service module"""
env = ServerEnv()
# Hermes CLI 调用函数
env.execute_hermes_command = awaitify(execute_hermes_command)
env.get_hermes_status = awaitify(get_hermes_status)
env.list_available_tools = awaitify(list_available_tools)
# 服务管理函数
env.start_hermes_service = awaitify(start_hermes_service)
env.stop_hermes_service = awaitify(stop_hermes_service)
env.get_service_config = awaitify(get_service_config)
return env
async def execute_hermes_command(command_args, user_context=None):
"""Execute hermes CLI command with user context"""
try:
# 构建 hermes 命令
cmd = ["python", "-m", "hermes_cli.main"] + command_args
# 设置环境变量(如果需要用户上下文)
env = os.environ.copy()
if user_context:
env['HERMES_USER_ID'] = user_context.get('user_id', '')
env['HERMES_SESSION_ID'] = user_context.get('session_id', '')
# 执行命令
result = subprocess.run(
cmd,
cwd="/d/hermesai/.hermes/hermes-agent",
capture_output=True,
text=True,
env=env,
timeout=300 # 5分钟超时
)
return {
'success': result.returncode == 0,
'stdout': result.stdout,
'stderr': result.stderr,
'returncode': result.returncode
}
except Exception as e:
return {
'success': False,
'stdout': '',
'stderr': str(e),
'returncode': -1
}
async def get_hermes_status():
"""Get current hermes agent status"""
return execute_hermes_command(['--version'])
async def list_available_tools():
"""List all available hermes tools"""
# 这里可以调用 hermes 的工具列表功能
return execute_hermes_command(['tools', 'list'])
async def start_hermes_service(config):
"""Start hermes service instance"""
# 启动服务的具体实现
return {'status': 'started', 'config': config}
async def stop_hermes_service(service_id):
"""Stop hermes service instance"""
return {'status': 'stopped', 'service_id': service_id}
async def get_service_config(service_id):
"""Get service configuration"""
return {'service_id': service_id, 'config': {}}

14
init/data.json Normal file
View File

@ -0,0 +1,14 @@
{
"hermes_services": [
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "default_hermes_service",
"owner_id": null,
"org_id": null,
"service_url": "http://localhost:9119",
"api_key": null,
"config": {"port": 9119, "host": "127.0.0.1"},
"status": "active"
}
]
}

31
json/hermes_services.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "hermes_services_crud",
"table": "hermes_services",
"operations": {
"create": {
"fields": ["name", "owner_id", "org_id", "service_url", "api_key", "config", "status"],
"required": ["name", "service_url"],
"validation": {
"name": "^[a-zA-Z0-9_-]{3,50}$",
"service_url": "^https?://.+"
}
},
"read": {
"fields": ["id", "name", "owner_id", "org_id", "service_url", "status", "created_at", "updated_at"],
"filters": ["id", "owner_id", "org_id", "status"],
"order_by": ["created_at DESC"]
},
"update": {
"fields": ["name", "service_url", "api_key", "config", "status"],
"required": [],
"validation": {
"service_url": "^https?://.+"
}
},
"delete": {
"soft_delete": true,
"status_field": "status",
"deleted_value": "inactive"
}
}
}

245
main.py Normal file
View File

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Hermes Service with complete session messaging support
"""
import os
import sys
import asyncio
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, Dict, Any, List
import json
import shutil
# Base Hermes Agent path
BASE_HERMES_PATH = "/d/hermesai/.hermes/hermes-agent"
# Clean user data directory structure: /d/hermesai/users/{user_id}/.hermes
USERS_BASE = "/d/hermesai/users"
app = FastAPI(title="Hermes Service API", version="1.2.0")
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# In-memory session storage for message history
active_sessions = {}
def get_user_hermes_path(user_id: str) -> str:
"""Get isolated Hermes environment path for a user"""
if not user_id or user_id == "anonymous":
user_id = "anonymous"
safe_user_id = "".join(c for c in user_id if c.isalnum() or c in "-_.")
return os.path.join(USERS_BASE, safe_user_id)
def ensure_user_hermes_env(user_id: str):
"""Ensure user has isolated Hermes environment"""
user_base_path = get_user_hermes_path(user_id)
user_hermes_path = os.path.join(user_base_path, "hermes-agent")
user_dot_hermes = os.path.join(user_base_path, ".hermes")
if not os.path.exists(user_hermes_path):
os.makedirs(user_base_path, exist_ok=True, mode=0o700)
shutil.copytree(
BASE_HERMES_PATH,
user_hermes_path,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns('.git', '__pycache__', '*.pyc', '.venv', 'web_dist')
)
os.makedirs(user_dot_hermes, exist_ok=True, mode=0o700)
venv_link = os.path.join(user_hermes_path, '.venv')
if not os.path.exists(venv_link):
os.symlink(
os.path.join(BASE_HERMES_PATH, '.venv'),
venv_link
)
return user_hermes_path
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "hermes-service", "multi_user": True}
@app.get("/api/v1/status")
async def get_hermes_status():
try:
result = await execute_hermes_command(["--version"], user_id=None)
return {"status": "running", "version": result.get("stdout", "").strip()}
except Exception as e:
return {"status": "error", "error": str(e)}
class SessionCreateRequest(BaseModel):
user_id: str
initial_message: Optional[str] = None
class CommandRequest(BaseModel):
command: list[str]
user_context: Optional[Dict[str, Any]] = None
timeout: int = 300
class SessionMessageRequest(BaseModel):
message: str
user_context: Optional[Dict[str, Any]] = None
@app.post("/api/v1/sessions")
async def create_session(request: SessionCreateRequest):
if not request.user_id:
raise HTTPException(status_code=400, detail="user_id is required")
session_id = str(uuid.uuid4())
user_hermes_path = ensure_user_hermes_env(request.user_id)
active_sessions[session_id] = {
"id": session_id,
"user_id": request.user_id,
"created_at": datetime.now().isoformat(),
"messages": [],
"status": "active"
}
if request.initial_message:
active_sessions[session_id]["messages"].append({
"role": "user",
"content": request.initial_message,
"timestamp": datetime.now().isoformat()
})
return {
"session_id": session_id,
"user_id": request.user_id,
"hermes_path": user_hermes_path,
"status": "created"
}
@app.post("/api/v1/execute")
async def execute_command(request: CommandRequest):
user_id = None
if request.user_context:
user_id = request.user_context.get("user_id")
result = await execute_hermes_command(
request.command,
user_id=user_id,
timeout=request.timeout
)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["stderr"])
return result
@app.post("/api/v1/sessions/{session_id}/messages")
async def send_session_message(session_id: str, request: SessionMessageRequest):
if session_id not in active_sessions:
raise HTTPException(status_code=404, detail="Session not found")
session_data = active_sessions[session_id]
user_id = session_data["user_id"]
session_data["messages"].append({
"role": "user",
"content": request.message,
"timestamp": datetime.now().isoformat()
})
# Correct way to send chat messages to Hermes
# Use the chat subcommand with the message as direct argument
command_args = ["chat", request.message]
result = await execute_hermes_command(
command_args,
user_id=user_id,
timeout=300
)
response_content = result.get("stdout", "") if result["success"] else result.get("stderr", "Command failed")
session_data["messages"].append({
"role": "assistant",
"content": response_content,
"timestamp": datetime.now().isoformat()
})
return {
"session_id": session_id,
"response": response_content,
"success": result["success"],
"message_count": len(session_data["messages"])
}
@app.get("/api/v1/sessions/{session_id}")
async def get_session(session_id: str):
if session_id not in active_sessions:
raise HTTPException(status_code=404, detail="Session not found")
session_data = active_sessions[session_id].copy()
session_data.pop("id", None)
return session_data
async def execute_hermes_command(command_args, user_id=None, timeout=300):
try:
if user_id:
user_base_path = get_user_hermes_path(user_id)
user_hermes_path = os.path.join(user_base_path, "hermes-agent")
hermes_dot_path = os.path.join(user_base_path, ".hermes")
else:
user_hermes_path = BASE_HERMES_PATH
hermes_dot_path = "/d/hermesai/.hermes"
python_path = "/d/hermesai/.hermes/hermes-agent/.venv/bin/python3"
cmd = [python_path, "-m", "hermes_cli.main"] + command_args
env = os.environ.copy()
env['HOME'] = hermes_dot_path
env['HERMES_USER_ID'] = str(user_id or 'anonymous')
env['HERMES_SESSION_ID'] = str(uuid.uuid4())
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=user_hermes_path,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
return {
'success': process.returncode == 0,
'stdout': stdout.decode('utf-8', errors='replace'),
'stderr': stderr.decode('utf-8', errors='replace'),
'returncode': process.returncode
}
except asyncio.TimeoutError:
process.kill()
await process.wait()
return {
'success': False,
'stdout': '',
'stderr': f'Command timed out after {timeout} seconds',
'returncode': -1
}
except Exception as e:
return {
'success': False,
'stdout': '',
'stderr': str(e),
'returncode': -1
}
os.makedirs(USERS_BASE, exist_ok=True, mode=0o755)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=9120, log_level="info")

View File

@ -0,0 +1,78 @@
{
"tablename": "hermes_services",
"fields": [
{
"name": "id",
"type": "uuid",
"primary_key": true,
"nullable": false
},
{
"name": "name",
"type": "varchar(255)",
"nullable": false
},
{
"name": "owner_id",
"type": "uuid",
"nullable": true,
"foreign_key": "rbac.users.id"
},
{
"name": "org_id",
"type": "uuid",
"nullable": true,
"foreign_key": "rbac.organizations.id"
},
{
"name": "service_url",
"type": "varchar(500)",
"nullable": false
},
{
"name": "api_key",
"type": "text",
"nullable": true
},
{
"name": "config",
"type": "json",
"nullable": true
},
{
"name": "status",
"type": "varchar(20)",
"nullable": false,
"default": "'active'"
},
{
"name": "created_at",
"type": "datetime",
"nullable": false,
"default": "CURRENT_TIMESTAMP"
},
{
"name": "updated_at",
"type": "datetime",
"nullable": false,
"default": "CURRENT_TIMESTAMP"
}
],
"indexes": [
{
"name": "idx_owner_services",
"fields": ["owner_id", "status"]
},
{
"name": "idx_org_services",
"fields": ["org_id", "status"]
},
{
"name": "idx_service_status",
"fields": ["status"]
}
],
"codes": {
"status": ["active", "inactive", "maintenance"]
}
}

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-service"
version = "1.0.0"
description = "Hermes Agent Service Web Application"
dependencies = [
"ahserver",
"bricks-framework"
]
[tool.setuptools.packages.find]
where = ["."]
include = ["hermes-service*"]

58
wwwroot/index.ui Normal file
View File

@ -0,0 +1,58 @@
{
"widgettype": "Page",
"options": {
"title": "Hermes Service Manager"
},
"subwidgets": [
{
"widgettype": "Card",
"options": {
"title": "Service Instances"
},
"subwidgets": [
{
"widgettype": "DataTable",
"options": {
"datasource": "hermes_services_crud",
"columns": [
{"field": "name", "header": "Service Name"},
{"field": "service_url", "header": "URL"},
{"field": "status", "header": "Status"},
{"field": "created_at", "header": "Created"}
]
}
}
]
},
{
"widgettype": "Card",
"options": {
"title": "Execute Command"
},
"subwidgets": [
{
"widgettype": "Form",
"options": {
"datasource": "hermes_command_form"
},
"subwidgets": [
{
"widgettype": "TextInput",
"options": {
"field": "command",
"label": "Command"
}
},
{
"widgettype": "Button",
"options": {
"text": "Execute",
"onclick": "executeHermesCommand()"
}
}
]
}
]
}
]
}