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
+14
View File
@@ -0,0 +1,14 @@
# Hermes Hub Environment Configuration
TZ=Africa/Tunis
HUB_PORT=8080
HUB_SECRET=change-this-secret-2026
# Tailscale Endpoints
NAS_TAILSCALE_IP=100.86.197.88
VPS_TAILSCALE_IP=100.94.90.119
# Backend URLs
HERMES_TT_URL=http://100.86.197.88:3010
HERMES_NYORA_URL=http://100.86.197.88:3020
HERMES_PERSO_URL=http://100.86.197.88:3031
HERMES_NABIL_URL=http://127.0.0.1:8642
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.env
.venv/
env/
venv/
.DS_Store
data/clones/*
!data/clones/.gitkeep
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.
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.11-slim
# Create user with standard UID/GID
RUN groupadd -g 100 hermesgroup 2>/dev/null || true && \
useradd -u 1026 -g 100 -m -s /bin/bash hermesuser 2>/dev/null || true
WORKDIR /app
# Install system dependencies & curl for healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Ensure data directory permissions
RUN mkdir -p /app/data/personas /app/data/clones && \
chown -R 1026:100 /app
USER 1026:100
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:8080/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+15
View File
@@ -0,0 +1,15 @@
# Hermes Hub
Portail unifié et sécurisé d'accès multi-univers pour la flotte d'instances Hermes (Tunisie Telecom, Nyora/Dr Nexum, Personnel/Famille, et Nabil Master VPS).
## Architecture
- **Hébergement** : VPS Contabo (`100.94.90.119`).
- **Communication** : Tunnel maillé **Tailscale** vers le NAS (`100.86.197.88`), aucun port ouvert au WAN direct sur le NAS.
- **Sécurité** : Authentification Cloudflare Access devant le point d'entrée Cloudflare Tunnel.
- **Cloisonnement** : Isolation stricte du contexte et de la mémoire JS à chaque basculement d'univers.
- **Design Tokens** : Palette et composants entièrement découplés via `static/css/tokens.css` (prêts pour intégration du système visuel Claude Design).
## Démarrage local / VPS
```bash
docker compose up -d
```
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 %}
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
universe_id: nabil
name: Hermes Nabil
tagline: Master Orchestration & VPS
tone: Technique, précis, systématique, orienté architecture
style: Code rigoureux, respect des contraintes infra
principles:
- Ne jamais exposer de secrets ou clés API en clair
- Exécution contrôlée des outils système
- Journalisation de toutes les actions dsh
system_prompt: |
Tu es l'agent master de Nabil hébergé sur le VPS Contabo.
Tu disposes d'un accès direct à DeepSeek Harness (DSH) pour les tâches de calcul et d'analyse.
skills_active:
- dsh-executor
- vps-ops
- dev-tools
version: 1
+18
View File
@@ -0,0 +1,18 @@
universe_id: nyora
name: Hermes Nyora
tagline: Venture & Dr Nexum
tone: Stratégique, direct, entrepreneurial, analytique
style: Orienté action, structuré, esprit business & veille
principles:
- Confidentialité des initiatives Nyora
- Pas de mention de Tunisie Telecom ou Zone Sud
- Footer Nyora officiel sur tous les artefacts
- Veille continue et synthèse opérationnelle
system_prompt: |
Tu es l'agent dédié aux projets entrepreneuriaux de Nyora et à l'initiative Dr Nexum.
Tu pilotes les veilles technologiques, la stratégie de contenu et le conseil IA appliqué.
skills_active:
- nyora-veille
- nyora-doc-api
- redact-pro
version: 1
+17
View File
@@ -0,0 +1,17 @@
universe_id: perso
name: Hermes Perso
tagline: Personnel & Famille
tone: Bienveillant, attentionné, clair, protecteur
style: Chaleureux, empathique et structuré
principles:
- Confidentialité médicale absolue (Yesmine, Nedya)
- Veille santé documentée et sources fiables uniquement
- Gestion logistique familiale fluide
system_prompt: |
Tu es le compagnon personnel et familial de Nabil.
Tu assistes au quotidien sur la gestion familiale, le suivi santé documenté et les loisirs.
skills_active:
- family-health
- media-pipeline
- notes-vault
version: 1
+18
View File
@@ -0,0 +1,18 @@
universe_id: tt
name: Hermes TT
tagline: Responsable Achats Zone Sud — Tunisie Telecom
tone: Professionnel, rigoureux, institutionnel
style: Synthétique, factuel, orienté conformité marchés
principles:
- Confidentialité stricte des données marchés et prix soumissionnaires
- Respect du Règlement Interne des Achats (RIA)
- Formatage monétaire obligatoire en SPACE_COMMA
- Jamais de mention explicite de prompt/IA dans les livrables
system_prompt: |
Tu es l'assistant dédié à la Direction Achats Zone Sud de Tunisie Telecom (7 gouvernorats).
Tu traites les dossiers RLA, les Appels d'Offres, les tableaux comparatifs et les bordereaux de prix.
skills_active:
- rla-marches
- ao-evaluation
- nyora-doc-api
version: 1
+26
View File
@@ -0,0 +1,26 @@
services:
hermes-hub:
build:
context: .
dockerfile: Dockerfile
container_name: hermes-hub
restart: unless-stopped
user: "1026:100"
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- TZ=Africa/Tunis
- HUB_DATA_DIR=/app/data
- NAS_TAILSCALE_IP=100.86.197.88
- VPS_TAILSCALE_IP=100.94.90.119
- HERMES_TT_URL=http://100.86.197.88:3010
- HERMES_NYORA_URL=http://100.86.197.88:3020
- HERMES_PERSO_URL=http://100.86.197.88:3031
- HERMES_NABIL_URL=http://127.0.0.1:8642
dns:
- 1.1.1.1
- 8.8.8.8
labels:
com.centurylinklabs.watchtower.enable: "false"
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.110.0
uvicorn[standard]>=0.28.0
httpx>=0.27.0
websockets>=12.0
jinja2>=3.1.3
pyyaml>=6.0.1
pydantic>=2.6.0
pydantic-settings>=2.2.0
python-multipart>=0.0.9
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+445
View File
@@ -0,0 +1,445 @@
/* Reset & Base Setup */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--hub-bg-canvas);
color: var(--hub-text-primary);
font-family: var(--hub-font-sans);
-webkit-font-smoothing: antialiased;
}
/* App Shell Container */
.hub-shell {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
}
/* Sidebar / Workspace Switcher */
.hub-sidebar {
width: var(--hub-sidebar-width);
height: 100%;
background-color: var(--hub-bg-sidebar);
border-right: 1px solid var(--hub-border-subtle);
display: flex;
flex-direction: column;
user-select: none;
z-index: 20;
transition: width 0.2s ease;
}
.hub-brand {
height: var(--hub-header-height);
padding: 0 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--hub-border-subtle);
}
.hub-logo-icon {
width: 32px;
height: 32px;
border-radius: var(--hub-radius-md);
background: linear-gradient(135deg, var(--hub-accent-current), #4f46e5);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: #ffffff;
box-shadow: 0 0 12px var(--hub-accent-glow-current);
}
.hub-brand-text {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--hub-text-primary);
}
.hub-brand-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
background: var(--hub-bg-active);
padding: 2px 6px;
border-radius: var(--hub-radius-full);
color: var(--hub-text-secondary);
border: 1px solid var(--hub-border-subtle);
}
/* Universes Switcher List */
.hub-nav-section {
padding: 1rem 0.75rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hub-text-muted);
}
.hub-universes-list {
list-style: none;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
overflow-y: auto;
}
.hub-universe-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.75rem;
border-radius: var(--hub-radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--hub-text-secondary);
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
position: relative;
}
.hub-universe-btn:hover {
background-color: var(--hub-bg-card-hover);
color: var(--hub-text-primary);
}
.hub-universe-btn.active {
background-color: var(--hub-bg-card);
border-color: var(--hub-border-medium);
color: var(--hub-text-primary);
}
.hub-universe-btn.active::before {
content: "";
position: absolute;
left: -0.5rem;
top: 15%;
height: 70%;
width: 4px;
border-radius: var(--hub-radius-full);
background-color: var(--item-accent, var(--hub-accent-current));
box-shadow: 0 0 8px var(--item-accent, var(--hub-accent-current));
}
.hub-universe-avatar {
width: 32px;
height: 32px;
border-radius: var(--hub-radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85rem;
background-color: var(--hub-bg-canvas);
color: var(--item-accent, var(--hub-text-primary));
border: 1px solid var(--hub-border-subtle);
flex-shrink: 0;
}
.hub-universe-btn.active .hub-universe-avatar {
background-color: var(--item-accent, var(--hub-accent-current));
color: #ffffff;
border-color: transparent;
}
.hub-universe-info {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.hub-universe-name {
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.hub-universe-tagline {
font-size: 0.725rem;
color: var(--hub-text-muted);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.hub-status-dot {
width: 8px;
height: 8px;
border-radius: var(--hub-radius-full);
background-color: var(--hub-status-offline);
flex-shrink: 0;
}
.hub-status-dot.online {
background-color: var(--hub-status-online);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
}
/* Sidebar Footer */
.hub-sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--hub-border-subtle);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hub-tool-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: var(--hub-radius-sm);
background: transparent;
border: 1px solid var(--hub-border-subtle);
color: var(--hub-text-secondary);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.hub-tool-btn:hover {
background-color: var(--hub-bg-card-hover);
color: var(--hub-text-primary);
}
/* Main View Area */
.hub-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background-color: var(--hub-bg-canvas);
}
/* Universe Topbar */
.hub-topbar {
height: var(--hub-header-height);
background-color: var(--hub-bg-sidebar);
border-bottom: 1px solid var(--hub-border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
z-index: 10;
}
.hub-topbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.hub-active-title {
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.hub-scope-pill {
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: var(--hub-radius-full);
background-color: var(--hub-bg-card);
border: 1px solid var(--hub-border-medium);
color: var(--hub-accent-current);
}
.hub-topbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* View Canvas (Iframe Workspace Container) */
.hub-canvas {
flex: 1;
width: 100%;
height: calc(100% - var(--hub-header-height));
position: relative;
background-color: var(--hub-bg-canvas);
}
.hub-workspace-iframe {
width: 100%;
height: 100%;
border: none;
background-color: transparent;
display: block;
}
/* Loading Overlay */
.hub-loader-overlay {
position: absolute;
inset: 0;
background-color: var(--hub-bg-canvas);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 5;
transition: opacity 0.2s ease;
}
.hub-loader-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.hub-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--hub-border-medium);
border-top-color: var(--hub-accent-current);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modal Overlay & Card */
.hub-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.hub-modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.hub-modal {
width: 90%;
max-width: 580px;
max-height: 85vh;
background-color: var(--hub-bg-card);
border: 1px solid var(--hub-border-medium);
border-radius: var(--hub-radius-lg);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
}
.hub-modal-header {
padding: 1.25rem;
border-bottom: 1px solid var(--hub-border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
}
.hub-modal-body {
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.hub-modal-footer {
padding: 1rem 1.25rem;
border-top: 1px solid var(--hub-border-subtle);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.hub-form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hub-form-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--hub-text-secondary);
}
.hub-input, .hub-textarea {
width: 100%;
background-color: var(--hub-bg-canvas);
border: 1px solid var(--hub-border-medium);
border-radius: var(--hub-radius-sm);
padding: 0.6rem 0.75rem;
color: var(--hub-text-primary);
font-family: var(--hub-font-sans);
font-size: 0.875rem;
}
.hub-input:focus, .hub-textarea:focus {
outline: none;
border-color: var(--hub-accent-current);
}
.hub-textarea {
min-height: 90px;
resize: vertical;
}
.hub-btn {
padding: 0.5rem 1rem;
border-radius: var(--hub-radius-sm);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.hub-btn-primary {
background-color: var(--hub-accent-current);
color: #ffffff;
}
.hub-btn-primary:hover {
opacity: 0.9;
}
.hub-btn-secondary {
background-color: var(--hub-bg-active);
color: var(--hub-text-secondary);
}
.hub-btn-secondary:hover {
background-color: var(--hub-bg-card-hover);
color: var(--hub-text-primary);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Hermes Hub — Design Tokens System
* Variables CSS modifiables et substituables par Claude Design
*/
:root {
/* Surfaces & Backgrounds (Dark Mode par défaut) */
--hub-bg-canvas: #0c0e12;
--hub-bg-sidebar: #13161c;
--hub-bg-card: #1a1e26;
--hub-bg-card-hover: #222733;
--hub-bg-active: #2b3242;
--hub-bg-glass: rgba(19, 22, 28, 0.85);
/* Borders & Dividers */
--hub-border-subtle: #232833;
--hub-border-medium: #303746;
--hub-border-focus: #4b5563;
/* Typography Colors */
--hub-text-primary: #f3f4f6;
--hub-text-secondary: #9ca3af;
--hub-text-muted: #6b7280;
--hub-text-inverse: #030712;
/* Status Colors */
--hub-status-online: #10b981;
--hub-status-degraded: #f59e0b;
--hub-status-offline: #ef4444;
/* Universe Accent Tokens (Substituables) */
--accent-tt: #3b82f6; /* TT Blue */
--accent-tt-glow: rgba(59, 130, 246, 0.25);
--accent-nyora: #10b981; /* Nyora Emerald */
--accent-nyora-glow: rgba(16, 185, 129, 0.25);
--accent-perso: #f97316; /* Perso Warm Orange */
--accent-perso-glow: rgba(249, 115, 22, 0.25);
--accent-nabil: #8b5cf6; /* Nabil Purple / Gold */
--accent-nabil-glow: rgba(139, 92, 246, 0.25);
/* Current Active Accent (Dynamically mapped via JS) */
--hub-accent-current: var(--accent-tt);
--hub-accent-glow-current: var(--accent-tt-glow);
/* Typography & Layout Metrics */
--hub-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--hub-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--hub-radius-sm: 6px;
--hub-radius-md: 10px;
--hub-radius-lg: 14px;
--hub-radius-full: 9999px;
--hub-sidebar-width: 260px;
--hub-sidebar-collapsed-width: 72px;
--hub-header-height: 56px;
}
Binary file not shown.
+214
View File
@@ -0,0 +1,214 @@
/**
* Hermes Hub — Client-side Workspace Switcher & Context Manager
* Garantit l'isolation stricte du state front-end entre univers
*/
(function () {
let activeUniverse = "tt";
const iframe = document.getElementById("workspace-iframe");
const loader = document.getElementById("hub-loader");
const activeTitle = document.getElementById("active-universe-name");
const activeScope = document.getElementById("active-universe-scope");
const activeTagline = document.getElementById("active-universe-tagline");
// Accent mappings
const ACCENT_MAP = {
tt: "var(--accent-tt)",
nyora: "var(--accent-nyora)",
perso: "var(--accent-perso)",
nabil: "var(--accent-nabil)"
};
const ACCENT_GLOW_MAP = {
tt: "var(--accent-tt-glow)",
nyora: "var(--accent-nyora-glow)",
perso: "var(--accent-perso-glow)",
nabil: "var(--accent-nabil-glow)"
};
function applyThemeTokens(universeId) {
const accent = ACCENT_MAP[universeId] || "var(--accent-tt)";
const glow = ACCENT_GLOW_MAP[universeId] || "var(--accent-tt-glow)";
document.documentElement.style.setProperty("--hub-accent-current", accent);
document.documentElement.style.setProperty("--hub-accent-glow-current", glow);
}
function switchUniverse(universeId) {
if (!universeId || (activeUniverse === universeId && iframe.src !== "about:blank")) {
return;
}
const btn = document.querySelector(`.hub-universe-btn[data-universe="${universeId}"]`);
if (!btn) return;
// 1. ISOLATION TOTALE DU STATE CLIENT
// Forcer le déchargement de l'iframe précédente pour éliminer tout résidu JS en mémoire
loader.classList.remove("hidden");
iframe.src = "about:blank";
// 2. Mettre à jour l'univers actif
activeUniverse = universeId;
// 3. Mettre à jour la classe active sur la sidebar
document.querySelectorAll(".hub-universe-btn").forEach((el) => el.classList.remove("active"));
btn.classList.add("active");
// 4. Appliquer les Design Tokens dynamiques
applyThemeTokens(universeId);
// 5. Mettre à jour les informations du header
const name = btn.dataset.name || universeId.toUpperCase();
const scope = btn.dataset.scope || universeId;
const tagline = btn.dataset.tagline || "";
if (activeTitle) activeTitle.textContent = name;
if (activeScope) activeScope.textContent = `Scope: ${scope}`;
if (activeTagline) activeTagline.textContent = tagline;
// 6. Charger le nouvel univers via le proxy dédié
const targetProxyUrl = `/u/${universeId}/`;
setTimeout(() => {
iframe.src = targetProxyUrl;
}, 50);
}
// Écouter le chargement de l'iframe
if (iframe) {
iframe.addEventListener("load", () => {
if (iframe.src !== "about:blank") {
loader.classList.add("hidden");
}
});
}
// Polling de l'état de santé des univers
async function refreshHealthStatus() {
try {
const res = await fetch("/api/universes");
if (!res.ok) return;
const data = await res.json();
data.forEach((u) => {
const dot = document.getElementById(`status-dot-${u.id}`);
if (dot) {
if (u.health && u.health.status === "online") {
dot.className = "hub-status-dot online";
dot.title = `En ligne (${u.health.latency_ms}ms)`;
} else {
dot.className = "hub-status-dot";
dot.title = `Hors ligne ou dégradé (${u.health.status})`;
}
}
});
} catch (e) {
console.warn("Health check error:", e);
}
}
// Modal Personnalité
const personaModal = document.getElementById("persona-modal");
const cloneModal = document.getElementById("clone-modal");
async function openPersonaModal() {
try {
const res = await fetch(`/api/universes/${activeUniverse}/persona`);
if (!res.ok) throw new Error("Erreur de chargement de la persona");
const persona = await res.json();
document.getElementById("persona-modal-title").textContent = `Personnalité — ${activeUniverse.toUpperCase()}`;
document.getElementById("persona-name").value = persona.name || "";
document.getElementById("persona-tagline").value = persona.tagline || "";
document.getElementById("persona-tone").value = persona.tone || "";
document.getElementById("persona-style").value = persona.style || "";
document.getElementById("persona-principles").value = (persona.principles || []).join("\n");
document.getElementById("persona-prompt").value = persona.system_prompt || "";
personaModal.classList.add("open");
} catch (e) {
alert("Impossible de charger la personnalité : " + e.message);
}
}
async function savePersona() {
const principlesText = document.getElementById("persona-principles").value;
const payload = {
universe_id: activeUniverse,
name: document.getElementById("persona-name").value,
tagline: document.getElementById("persona-tagline").value,
tone: document.getElementById("persona-tone").value,
style: document.getElementById("persona-style").value,
principles: principlesText.split("\n").map(s => s.trim()).filter(Boolean),
system_prompt: document.getElementById("persona-prompt").value,
version: 1
};
try {
const res = await fetch(`/api/universes/${activeUniverse}/persona`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Erreur de sauvegarde");
personaModal.classList.remove("open");
} catch (e) {
alert("Erreur lors de la sauvegarde : " + e.message);
}
}
function openCloneModal() {
document.getElementById("clone-source-name").textContent = activeUniverse.toUpperCase();
document.getElementById("clone-name").value = `Clone ${activeUniverse.toUpperCase()} - Test`;
cloneModal.classList.add("open");
}
async function submitClone() {
const cloneName = document.getElementById("clone-name").value;
if (!cloneName) return alert("Veuillez saisir un nom pour le clone.");
try {
const res = await fetch(`/api/universes/${activeUniverse}/clone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clone_name: cloneName })
});
if (!res.ok) throw new Error("Erreur lors du clonage");
const data = await res.json();
alert(`Profil cloné avec succès : ${data.clone.clone_id}`);
cloneModal.classList.remove("open");
} catch (e) {
alert("Erreur de clonage : " + e.message);
}
}
// Setup Event Listeners
document.addEventListener("DOMContentLoaded", () => {
// Boutons de changement d'univers
document.querySelectorAll(".hub-universe-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const uId = btn.dataset.universe;
switchUniverse(uId);
});
});
// Outils Footer
document.getElementById("btn-edit-persona")?.addEventListener("click", openPersonaModal);
document.getElementById("btn-clone-universe")?.addEventListener("click", openCloneModal);
document.getElementById("btn-save-persona")?.addEventListener("click", savePersona);
document.getElementById("btn-submit-clone")?.addEventListener("click", submitClone);
// Boutons fermer modales
document.querySelectorAll(".hub-modal-close").forEach((btn) => {
btn.addEventListener("click", () => {
personaModal.classList.remove("open");
cloneModal.classList.remove("open");
});
});
// Démarrage : activer le premier univers (TT)
switchUniverse("tt");
// Lancer la vérification de santé
refreshHealthStatus();
setInterval(refreshHealthStatus, 15000);
});
})();