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:
commit
7d70f362b2
24
README.md
Normal file
24
README.md
Normal 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
27
build.sh
Executable 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!"
|
||||
0
hermes-service/__init__.py
Normal file
0
hermes-service/__init__.py
Normal file
79
hermes-service/init.py
Normal file
79
hermes-service/init.py
Normal 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
14
init/data.json
Normal 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
31
json/hermes_services.json
Normal 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
245
main.py
Normal 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")
|
||||
78
models/hermes_services.json
Normal file
78
models/hermes_services.json
Normal 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
16
pyproject.toml
Normal 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
58
wwwroot/index.ui
Normal 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()"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user