feat: initialisation Hermes Hub — interface multi-univers securisee (FastAPI, proxy async, design tokens)

This commit is contained in:
bolbol
2026-08-19 20:46:08 +01:00
commit d107d40a30
59 changed files with 1538 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
"""
Hermes Hub - Multi-universe secure portal for Hermes instances.
"""
__version__ = "1.0.0"
+99
View File
@@ -0,0 +1,99 @@
import os
from pathlib import Path
from typing import Dict, Any, List
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.getenv("HUB_DATA_DIR", BASE_DIR / "data"))
PERSONAS_DIR = DATA_DIR / "personas"
CLONES_DIR = DATA_DIR / "clones"
class UniverseConfig(BaseModel):
id: str
name: str
tagline: str
description: str
backend_url: str
scope: str
accent_token: str
icon: str
persona_file: str
enabled: bool = True
class Settings(BaseSettings):
app_name: str = "Hermes Hub"
host: str = "0.0.0.0"
port: int = 8080
debug: bool = False
# NAS & VPS Network Configurations
nas_tailscale_ip: str = os.getenv("NAS_TAILSCALE_IP", "100.86.197.88")
vps_tailscale_ip: str = os.getenv("VPS_TAILSCALE_IP", "100.94.90.119")
# Backends
hermes_tt_url: str = os.getenv("HERMES_TT_URL", "http://100.86.197.88:3010")
hermes_nyora_url: str = os.getenv("HERMES_NYORA_URL", "http://100.86.197.88:3020")
hermes_perso_url: str = os.getenv("HERMES_PERSO_URL", "http://100.86.197.88:3031")
hermes_nabil_url: str = os.getenv("HERMES_NABIL_URL", "http://127.0.0.1:8642")
# Hub Secret for internal session sealing if needed
hub_secret: str = os.getenv("HUB_SECRET", "hermes-hub-master-key-2026")
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()
UNIVERSES: Dict[str, UniverseConfig] = {
"tt": UniverseConfig(
id="tt",
name="Tunisie Telecom",
tagline="Achats Zone Sud",
description="Direction Régionale — Marchés, RLA & Appels d'Offres",
backend_url=settings.hermes_tt_url,
scope="tt",
accent_token="--accent-tt",
icon="briefcase",
persona_file="tt.yaml"
),
"nyora": UniverseConfig(
id="nyora",
name="Nyora",
tagline="Venture & Dr Nexum",
description="Projets entrepreneuriaux, conseil & veille stratégique",
backend_url=settings.hermes_nyora_url,
scope="nyora",
accent_token="--accent-nyora",
icon="sparkles",
persona_file="nyora.yaml"
),
"perso": UniverseConfig(
id="perso",
name="Personnel",
tagline="Famille & Santé",
description="Espace privé, santé familiale, gestion du quotidien",
backend_url=settings.hermes_perso_url,
scope="perso",
accent_token="--accent-perso",
icon="home",
persona_file="perso.yaml"
),
"nabil": UniverseConfig(
id="nabil",
name="Nabil Master",
tagline="Orchestration & DSH",
description="Master Agent VPS, exécution de code & DeepSeek Harness",
backend_url=settings.hermes_nabil_url,
scope="nabil",
accent_token="--accent-nabil",
icon="terminal",
persona_file="nabil.yaml"
)
}
def get_universe(universe_id: str) -> UniverseConfig:
if universe_id not in UNIVERSES:
raise KeyError(f"Universe '{universe_id}' does not exist.")
return UNIVERSES[universe_id]
+51
View File
@@ -0,0 +1,51 @@
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pathlib import Path
from contextlib import asynccontextmanager
from app.config import settings, UNIVERSES, BASE_DIR
from app.routers import api, proxy
from app.proxy import close_http_client
from app.personas import ensure_dirs
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_dirs()
yield
await close_http_client()
app = FastAPI(
title=settings.app_name,
version="1.0.0",
lifespan=lifespan
)
# Mount static files
static_dir = BASE_DIR / "static"
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Templates
templates_dir = BASE_DIR / "app" / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Include Routers
app.include_router(api.router)
app.include_router(proxy.router)
@app.get("/", response_class=HTMLResponse)
async def index_view(request: Request):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"universes": UNIVERSES,
"app_name": settings.app_name
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=settings.debug)
+87
View File
@@ -0,0 +1,87 @@
import os
import yaml
import time
from pathlib import Path
from typing import Dict, Any, Optional, List
from pydantic import BaseModel, Field
from app.config import PERSONAS_DIR, CLONES_DIR, get_universe
class PersonaModel(BaseModel):
universe_id: str
name: str
tagline: str
tone: str
style: str
principles: List[str] = Field(default_factory=list)
system_prompt: str = ""
skills_active: List[str] = Field(default_factory=list)
version: int = 1
updated_at: str = Field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
def ensure_dirs():
PERSONAS_DIR.mkdir(parents=True, exist_ok=True)
CLONES_DIR.mkdir(parents=True, exist_ok=True)
def get_persona_path(universe_id: str) -> Path:
ensure_dirs()
cfg = get_universe(universe_id)
return PERSONAS_DIR / cfg.persona_file
def load_persona(universe_id: str) -> Dict[str, Any]:
path = get_persona_path(universe_id)
if not path.exists():
return {
"universe_id": universe_id,
"name": universe_id.upper(),
"tagline": "Profil standard",
"tone": "Professionnel et concis",
"style": "Direct",
"principles": [],
"system_prompt": "",
"skills_active": [],
"version": 1,
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S")
}
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def save_persona(universe_id: str, data: Dict[str, Any]) -> None:
path = get_persona_path(universe_id)
data["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
with open(path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
def list_clones() -> List[Dict[str, Any]]:
ensure_dirs()
clones = []
for p in CLONES_DIR.glob("*.yaml"):
try:
with open(p, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data:
data["clone_file"] = p.name
clones.append(data)
except Exception:
continue
return clones
def clone_persona(universe_id: str, clone_name: str, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
ensure_dirs()
base_data = load_persona(universe_id)
clone_id = f"{universe_id}-clone-{int(time.time())}"
clone_data = {
**base_data,
"clone_id": clone_id,
"clone_name": clone_name,
"parent_universe": universe_id,
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"version": base_data.get("version", 1) + 1
}
if overrides:
clone_data.update(overrides)
clone_file = CLONES_DIR / f"{clone_id}.yaml"
with open(clone_file, "w", encoding="utf-8") as f:
yaml.safe_dump(clone_data, f, allow_unicode=True, sort_keys=False)
return clone_data
+122
View File
@@ -0,0 +1,122 @@
import httpx
from typing import AsyncGenerator, Dict, Any, Optional
from fastapi import Request, Response
from fastapi.responses import StreamingResponse
import logging
logger = logging.getLogger("hermes_hub.proxy")
HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"content-length"
}
# Global async client for connection pooling
_http_client: Optional[httpx.AsyncClient] = None
def get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=120.0, write=60.0, pool=30.0),
follow_redirects=True,
limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)
)
return _http_client
async def close_http_client():
global _http_client
if _http_client and not _http_client.is_closed:
await _http_client.aclose()
_http_client = None
async def check_backend_health(backend_url: str) -> Dict[str, Any]:
client = get_http_client()
try:
# Test root or /health
res = await client.get(backend_url, timeout=3.0)
return {
"status": "online" if res.status_code < 500 else "degraded",
"status_code": res.status_code,
"latency_ms": int(res.elapsed.total_seconds() * 1000)
}
except Exception as e:
return {
"status": "offline",
"error": str(e),
"latency_ms": None
}
async def proxy_request(
request: Request,
backend_url: str,
path: str
) -> Response:
client = get_http_client()
# Strip trailing slash from backend_url and leading from path
base_url = backend_url.rstrip("/")
sub_path = path.lstrip("/")
target_url = f"{base_url}/{sub_path}" if sub_path else base_url
if request.url.query:
target_url = f"{target_url}?{request.url.query}"
# Filter incoming request headers
req_headers = {}
for key, value in request.headers.items():
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host":
req_headers[key] = value
body = await request.body()
try:
upstream_req = client.build_request(
method=request.method,
url=target_url,
headers=req_headers,
content=body
)
upstream_res = await client.send(upstream_req, stream=True)
# Filter response headers
res_headers = {}
for key, value in upstream_res.headers.items():
if key.lower() not in HOP_BY_HOP_HEADERS:
res_headers[key] = value
async def stream_content() -> AsyncGenerator[bytes, None]:
try:
async for chunk in upstream_res.aiter_raw():
yield chunk
finally:
await upstream_res.aclose()
return StreamingResponse(
stream_content(),
status_code=upstream_res.status_code,
headers=res_headers,
media_type=upstream_res.headers.get("content-type")
)
except httpx.ConnectError:
logger.error(f"Failed to connect to backend at {target_url}")
return Response(
content=f"<html><head><title>Backend Unavailable</title></head><body style='background:#121212;color:#ef4444;font-family:sans-serif;padding:2rem;'><h2>Instance Hermes inaccessible</h2><p>Le backend sur <code>{backend_url}</code> ne répond pas. Vérifiez le tunnel Tailscale ou l'état du conteneur.</p></body></html>",
status_code=502,
media_type="text/html"
)
except Exception as e:
logger.exception(f"Proxy error for {target_url}: {e}")
return Response(
content=f"Proxy Error: {str(e)}",
status_code=500,
media_type="text/plain"
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
# Routers module
+95
View File
@@ -0,0 +1,95 @@
from fastapi import APIRouter, HTTPException, Depends, Body
from typing import Dict, Any, List, Optional
import asyncio
from app.config import UNIVERSES, UniverseConfig, get_universe
from app.personas import load_persona, save_persona, clone_persona, list_clones
from app.proxy import check_backend_health
router = APIRouter(prefix="/api", tags=["Hub Management"])
@router.get("/universes")
async def list_universes():
"""
Returns the list of universes with live connectivity status and persona summaries.
"""
results = []
# Run health checks concurrently
health_tasks = [check_backend_health(u.backend_url) for u in UNIVERSES.values()]
health_results = await asyncio.gather(*health_tasks, return_exceptions=True)
for (u_id, u_cfg), health in zip(UNIVERSES.items(), health_results):
persona = load_persona(u_id)
health_data = health if isinstance(health, dict) else {"status": "error", "error": str(health)}
results.append({
"id": u_cfg.id,
"name": u_cfg.name,
"tagline": u_cfg.tagline,
"description": u_cfg.description,
"scope": u_cfg.scope,
"accent_token": u_cfg.accent_token,
"icon": u_cfg.icon,
"backend_url": u_cfg.backend_url,
"health": health_data,
"persona": {
"tone": persona.get("tone", ""),
"style": persona.get("style", ""),
"principles_count": len(persona.get("principles", [])),
"version": persona.get("version", 1),
"updated_at": persona.get("updated_at", "")
}
})
return results
@router.get("/universes/{universe_id}")
async def get_universe_details(universe_id: str):
if universe_id not in UNIVERSES:
raise HTTPException(status_code=404, detail="Universe not found")
u_cfg = UNIVERSES[universe_id]
health = await check_backend_health(u_cfg.backend_url)
persona = load_persona(universe_id)
return {
"config": u_cfg.dict(),
"health": health,
"persona": persona
}
@router.get("/universes/{universe_id}/persona")
async def get_universe_persona(universe_id: str):
if universe_id not in UNIVERSES:
raise HTTPException(status_code=404, detail="Universe not found")
return load_persona(universe_id)
@router.put("/universes/{universe_id}/persona")
async def update_universe_persona(universe_id: str, data: Dict[str, Any] = Body(...)):
if universe_id not in UNIVERSES:
raise HTTPException(status_code=404, detail="Universe not found")
save_persona(universe_id, data)
return {"status": "saved", "persona": load_persona(universe_id)}
@router.post("/universes/{universe_id}/clone")
async def clone_universe_profile(
universe_id: str,
clone_name: str = Body(..., embed=True),
overrides: Optional[Dict[str, Any]] = Body(None, embed=True)
):
if universe_id not in UNIVERSES:
raise HTTPException(status_code=404, detail="Universe not found")
cloned = clone_persona(universe_id, clone_name, overrides)
return {"status": "cloned", "clone": cloned}
@router.get("/clones")
async def get_clones():
return list_clones()
@router.get("/health")
async def hub_health():
return {
"status": "healthy",
"universes_configured": len(UNIVERSES)
}
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Request, HTTPException
from app.config import UNIVERSES
from app.proxy import proxy_request
router = APIRouter(prefix="/u", tags=["Universe Proxy"])
@router.api_route("/{universe_id}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
@router.api_route("/{universe_id}/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
@router.api_route("/{universe_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
async def dynamic_universe_proxy(universe_id: str, request: Request, path: str = ""):
if universe_id not in UNIVERSES:
raise HTTPException(status_code=404, detail=f"Universe '{universe_id}' not found.")
universe = UNIVERSES[universe_id]
return await proxy_request(
request=request,
backend_url=universe.backend_url,
path=path
)
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Design Tokens System (Substituable par Claude Design) -->
<link rel="stylesheet" href="/static/css/tokens.css">
<link rel="stylesheet" href="/static/css/style.css">
{% block extra_head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
<script src="/static/js/hub.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block content %}
<div class="hub-shell">
<!-- Sidebar / Workspace Switcher -->
<aside class="hub-sidebar">
<div class="hub-brand">
<div class="hub-logo-icon">H</div>
<div class="hub-brand-text">Hermes Hub</div>
<div class="hub-brand-badge">v1.0</div>
</div>
<div class="hub-nav-section">Univers Hermes</div>
<ul class="hub-universes-list">
{% for u_id, u in universes.items() %}
<li>
<button
class="hub-universe-btn {% if loop.first %}active{% endif %}"
data-universe="{{ u.id }}"
data-name="{{ u.name }}"
data-scope="{{ u.scope }}"
data-tagline="{{ u.tagline }}"
style="--item-accent: var({{ u.accent_token }});"
>
<div class="hub-universe-avatar">
{{ u.name[:2].upper() }}
</div>
<div class="hub-universe-info">
<div class="hub-universe-name">{{ u.name }}</div>
<div class="hub-universe-tagline">{{ u.tagline }}</div>
</div>
<div id="status-dot-{{ u.id }}" class="hub-status-dot" title="Vérification du statut..."></div>
</button>
</li>
{% endfor %}
</ul>
<!-- Sidebar Tools Footer -->
<div class="hub-sidebar-footer">
<button id="btn-edit-persona" class="hub-tool-btn" type="button">
<span>⚙️</span>
<span>Personnalité & Ton</span>
</button>
<button id="btn-clone-universe" class="hub-tool-btn" type="button">
<span>🧬</span>
<span>Cloner ce profil</span>
</button>
</div>
</aside>
<!-- Main View Area -->
<main class="hub-main">
<!-- Universe Topbar -->
<header class="hub-topbar">
<div class="hub-topbar-left">
<h1 id="active-universe-name" class="hub-active-title">Tunisie Telecom</h1>
<span id="active-universe-scope" class="hub-scope-pill">Scope: tt</span>
<span id="active-universe-tagline" style="font-size: 0.8rem; color: var(--hub-text-muted);">Achats Zone Sud</span>
</div>
<div class="hub-topbar-right">
<span style="font-size: 0.75rem; color: var(--hub-text-muted);">Session sécurisée VPS ↔ NAS</span>
</div>
</header>
<!-- Canvas / Iframe Container -->
<div class="hub-canvas">
<!-- Loading Indicator -->
<div id="hub-loader" class="hub-loader-overlay">
<div class="hub-spinner"></div>
<p style="font-size: 0.85rem; color: var(--hub-text-secondary);">Connexion à l'instance Hermes...</p>
</div>
<!-- Isolated Workspace View -->
<iframe
id="workspace-iframe"
class="hub-workspace-iframe"
src="about:blank"
title="Hermes Workspace Frame"
></iframe>
</div>
</main>
</div>
<!-- Modal Edition Personnalité -->
<div id="persona-modal" class="hub-modal-backdrop">
<div class="hub-modal">
<div class="hub-modal-header">
<h3 id="persona-modal-title" style="font-size: 1.05rem; font-weight: 600;">Personnalité de l'Univers</h3>
<button class="hub-modal-close" style="background:none;border:none;color:var(--hub-text-muted);font-size:1.2rem;cursor:pointer;">&times;</button>
</div>
<div class="hub-modal-body">
<div class="hub-form-group">
<label class="hub-form-label">Nom d'affichage</label>
<input id="persona-name" class="hub-input" type="text">
</div>
<div class="hub-form-group">
<label class="hub-form-label">Slogan / Rôle</label>
<input id="persona-tagline" class="hub-input" type="text">
</div>
<div class="hub-form-group">
<label class="hub-form-label">Ton de conversation</label>
<input id="persona-tone" class="hub-input" type="text">
</div>
<div class="hub-form-group">
<label class="hub-form-label">Style de rédaction</label>
<input id="persona-style" class="hub-input" type="text">
</div>
<div class="hub-form-group">
<label class="hub-form-label">Principes directeurs (un par ligne)</label>
<textarea id="persona-principles" class="hub-textarea" rows="3"></textarea>
</div>
<div class="hub-form-group">
<label class="hub-form-label">System Prompt / Instructions spécifiques</label>
<textarea id="persona-prompt" class="hub-textarea" rows="4"></textarea>
</div>
</div>
<div class="hub-modal-footer">
<button class="hub-btn hub-btn-secondary hub-modal-close" type="button">Annuler</button>
<button id="btn-save-persona" class="hub-btn hub-btn-primary" type="button">Enregistrer</button>
</div>
</div>
</div>
<!-- Modal Clonage Profil -->
<div id="clone-modal" class="hub-modal-backdrop">
<div class="hub-modal" style="max-width: 440px;">
<div class="hub-modal-header">
<h3 style="font-size: 1.05rem; font-weight: 600;">Cloner le profil <span id="clone-source-name"></span></h3>
<button class="hub-modal-close" style="background:none;border:none;color:var(--hub-text-muted);font-size:1.2rem;cursor:pointer;">&times;</button>
</div>
<div class="hub-modal-body">
<p style="font-size: 0.825rem; color: var(--hub-text-secondary); line-height: 1.4;">
Le clonage crée une copie autonome des personas, règles et instructions sans impacter l'instance de production active.
</p>
<div class="hub-form-group">
<label class="hub-form-label">Nom du clone</label>
<input id="clone-name" class="hub-input" type="text" placeholder="Ex: TT - Variante Audit 2026">
</div>
</div>
<div class="hub-modal-footer">
<button class="hub-btn hub-btn-secondary hub-modal-close" type="button">Annuler</button>
<button id="btn-submit-clone" class="hub-btn hub-btn-primary" type="button">Créer le clone</button>
</div>
</div>
</div>
{% endblock %}