commit d107d40a30248b6ef77aba3f7622462b38b1718a Author: bolbol Date: Wed Aug 19 20:46:08 2026 +0100 feat: initialisation Hermes Hub — interface multi-univers securisee (FastAPI, proxy async, design tokens) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66ec3fb --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..262683c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.venv/ +env/ +venv/ +.DS_Store +data/clones/* +!data/clones/.gitkeep diff --git a/@eaDir/.env.example@SynoEAStream b/@eaDir/.env.example@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/.env.example@SynoEAStream differ diff --git a/@eaDir/.git@SynoEAStream b/@eaDir/.git@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/.git@SynoEAStream differ diff --git a/@eaDir/.gitignore@SynoEAStream b/@eaDir/.gitignore@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/.gitignore@SynoEAStream differ diff --git a/@eaDir/Dockerfile@SynoEAStream b/@eaDir/Dockerfile@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/Dockerfile@SynoEAStream differ diff --git a/@eaDir/README.md@SynoEAStream b/@eaDir/README.md@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/README.md@SynoEAStream differ diff --git a/@eaDir/app@SynoEAStream b/@eaDir/app@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/app@SynoEAStream differ diff --git a/@eaDir/data@SynoEAStream b/@eaDir/data@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/data@SynoEAStream differ diff --git a/@eaDir/docker-compose.yml@SynoEAStream b/@eaDir/docker-compose.yml@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/docker-compose.yml@SynoEAStream differ diff --git a/@eaDir/requirements.txt@SynoEAStream b/@eaDir/requirements.txt@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/requirements.txt@SynoEAStream differ diff --git a/@eaDir/static@SynoEAStream b/@eaDir/static@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/@eaDir/static@SynoEAStream differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e773b2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..366c9a0 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app/@eaDir/__init__.py@SynoEAStream b/app/@eaDir/__init__.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/__init__.py@SynoEAStream differ diff --git a/app/@eaDir/__pycache__@SynoEAStream b/app/@eaDir/__pycache__@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/__pycache__@SynoEAStream differ diff --git a/app/@eaDir/config.py@SynoEAStream b/app/@eaDir/config.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/config.py@SynoEAStream differ diff --git a/app/@eaDir/main.py@SynoEAStream b/app/@eaDir/main.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/main.py@SynoEAStream differ diff --git a/app/@eaDir/personas.py@SynoEAStream b/app/@eaDir/personas.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/personas.py@SynoEAStream differ diff --git a/app/@eaDir/proxy.py@SynoEAStream b/app/@eaDir/proxy.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/proxy.py@SynoEAStream differ diff --git a/app/@eaDir/routers@SynoEAStream b/app/@eaDir/routers@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/routers@SynoEAStream differ diff --git a/app/@eaDir/templates@SynoEAStream b/app/@eaDir/templates@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/@eaDir/templates@SynoEAStream differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..74b3e52 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,4 @@ +""" +Hermes Hub - Multi-universe secure portal for Hermes instances. +""" +__version__ = "1.0.0" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..0720ecd --- /dev/null +++ b/app/config.py @@ -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] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b22776d --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/personas.py b/app/personas.py new file mode 100644 index 0000000..b77b3df --- /dev/null +++ b/app/personas.py @@ -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 diff --git a/app/proxy.py b/app/proxy.py new file mode 100644 index 0000000..67e5335 --- /dev/null +++ b/app/proxy.py @@ -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"Backend Unavailable

Instance Hermes inaccessible

Le backend sur {backend_url} ne répond pas. Vérifiez le tunnel Tailscale ou l'état du conteneur.

", + 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" + ) diff --git a/app/routers/@eaDir/__init__.py@SynoEAStream b/app/routers/@eaDir/__init__.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/routers/@eaDir/__init__.py@SynoEAStream differ diff --git a/app/routers/@eaDir/__pycache__@SynoEAStream b/app/routers/@eaDir/__pycache__@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/routers/@eaDir/__pycache__@SynoEAStream differ diff --git a/app/routers/@eaDir/api.py@SynoEAStream b/app/routers/@eaDir/api.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/routers/@eaDir/api.py@SynoEAStream differ diff --git a/app/routers/@eaDir/proxy.py@SynoEAStream b/app/routers/@eaDir/proxy.py@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/routers/@eaDir/proxy.py@SynoEAStream differ diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..22da696 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# Routers module diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..f6613e2 --- /dev/null +++ b/app/routers/api.py @@ -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) + } diff --git a/app/routers/proxy.py b/app/routers/proxy.py new file mode 100644 index 0000000..c437767 --- /dev/null +++ b/app/routers/proxy.py @@ -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 + ) diff --git a/app/templates/@eaDir/base.html@SynoEAStream b/app/templates/@eaDir/base.html@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/templates/@eaDir/base.html@SynoEAStream differ diff --git a/app/templates/@eaDir/index.html@SynoEAStream b/app/templates/@eaDir/index.html@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/app/templates/@eaDir/index.html@SynoEAStream differ diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d817f54 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,20 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + + + {% block extra_head %}{% endblock %} + + + {% block content %}{% endblock %} + + + {% block extra_scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..a29fd31 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + + +
+ +
+
+

Tunisie Telecom

+ Scope: tt + Achats Zone Sud +
+ +
+ Session sécurisée VPS ↔ NAS +
+
+ + +
+ +
+
+

Connexion à l'instance Hermes...

+
+ + + +
+
+
+ + +
+
+
+

Personnalité de l'Univers

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+

Cloner le profil

+ +
+
+

+ Le clonage crée une copie autonome des personas, règles et instructions sans impacter l'instance de production active. +

+
+ + +
+
+ +
+
+{% endblock %} diff --git a/data/@eaDir/clones@SynoEAStream b/data/@eaDir/clones@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/data/@eaDir/clones@SynoEAStream differ diff --git a/data/@eaDir/personas@SynoEAStream b/data/@eaDir/personas@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/data/@eaDir/personas@SynoEAStream differ diff --git a/data/clones/.gitkeep b/data/clones/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/personas/@eaDir/nabil.yaml@SynoEAStream b/data/personas/@eaDir/nabil.yaml@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/data/personas/@eaDir/nabil.yaml@SynoEAStream differ diff --git a/data/personas/@eaDir/nyora.yaml@SynoEAStream b/data/personas/@eaDir/nyora.yaml@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/data/personas/@eaDir/nyora.yaml@SynoEAStream differ diff --git a/data/personas/@eaDir/perso.yaml@SynoEAStream b/data/personas/@eaDir/perso.yaml@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/data/personas/@eaDir/perso.yaml@SynoEAStream differ diff --git a/data/personas/@eaDir/tt.yaml@SynoEAStream b/data/personas/@eaDir/tt.yaml@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/data/personas/@eaDir/tt.yaml@SynoEAStream differ diff --git a/data/personas/nabil.yaml b/data/personas/nabil.yaml new file mode 100644 index 0000000..bf43769 --- /dev/null +++ b/data/personas/nabil.yaml @@ -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 diff --git a/data/personas/nyora.yaml b/data/personas/nyora.yaml new file mode 100644 index 0000000..c6fe4ec --- /dev/null +++ b/data/personas/nyora.yaml @@ -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 diff --git a/data/personas/perso.yaml b/data/personas/perso.yaml new file mode 100644 index 0000000..5e81d4d --- /dev/null +++ b/data/personas/perso.yaml @@ -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 diff --git a/data/personas/tt.yaml b/data/personas/tt.yaml new file mode 100644 index 0000000..51237ce --- /dev/null +++ b/data/personas/tt.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5c35e00 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a466978 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/@eaDir/css@SynoEAStream b/static/@eaDir/css@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/static/@eaDir/css@SynoEAStream differ diff --git a/static/@eaDir/js@SynoEAStream b/static/@eaDir/js@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/static/@eaDir/js@SynoEAStream differ diff --git a/static/css/@eaDir/style.css@SynoEAStream b/static/css/@eaDir/style.css@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/static/css/@eaDir/style.css@SynoEAStream differ diff --git a/static/css/@eaDir/tokens.css@SynoEAStream b/static/css/@eaDir/tokens.css@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/static/css/@eaDir/tokens.css@SynoEAStream differ diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..ccb2bcb --- /dev/null +++ b/static/css/style.css @@ -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); +} diff --git a/static/css/tokens.css b/static/css/tokens.css new file mode 100644 index 0000000..0572495 --- /dev/null +++ b/static/css/tokens.css @@ -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; +} diff --git a/static/js/@eaDir/hub.js@SynoEAStream b/static/js/@eaDir/hub.js@SynoEAStream new file mode 100644 index 0000000..1ca6728 Binary files /dev/null and b/static/js/@eaDir/hub.js@SynoEAStream differ diff --git a/static/js/hub.js b/static/js/hub.js new file mode 100644 index 0000000..9989864 --- /dev/null +++ b/static/js/hub.js @@ -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); + }); +})();