feat: initialisation Hermes Hub — interface multi-univers securisee (FastAPI, proxy async, design tokens)
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Hermes Hub - Multi-universe secure portal for Hermes instances.
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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.
@@ -0,0 +1 @@
|
||||
# Routers module
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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;">×</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;">×</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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
@@ -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);
|
||||
}
|
||||
@@ -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.
@@ -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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user