From d107d40a30248b6ef77aba3f7622462b38b1718a Mon Sep 17 00:00:00 2001 From: bolbol Date: Wed, 19 Aug 2026 20:46:08 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20initialisation=20Hermes=20Hub=20?= =?UTF-8?q?=E2=80=94=20interface=20multi-univers=20securisee=20(FastAPI,?= =?UTF-8?q?=20proxy=20async,=20design=20tokens)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 + .gitignore | 11 + @eaDir/.env.example@SynoEAStream | Bin 0 -> 163 bytes @eaDir/.git@SynoEAStream | Bin 0 -> 163 bytes @eaDir/.gitignore@SynoEAStream | Bin 0 -> 163 bytes @eaDir/Dockerfile@SynoEAStream | Bin 0 -> 163 bytes @eaDir/README.md@SynoEAStream | Bin 0 -> 163 bytes @eaDir/app@SynoEAStream | Bin 0 -> 163 bytes @eaDir/data@SynoEAStream | Bin 0 -> 163 bytes @eaDir/docker-compose.yml@SynoEAStream | Bin 0 -> 163 bytes @eaDir/requirements.txt@SynoEAStream | Bin 0 -> 163 bytes @eaDir/static@SynoEAStream | Bin 0 -> 163 bytes Dockerfile | 30 ++ README.md | 15 + app/@eaDir/__init__.py@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/__pycache__@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/config.py@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/main.py@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/personas.py@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/proxy.py@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/routers@SynoEAStream | Bin 0 -> 163 bytes app/@eaDir/templates@SynoEAStream | Bin 0 -> 163 bytes app/__init__.py | 4 + app/config.py | 99 +++++ app/main.py | 51 +++ app/personas.py | 87 ++++ app/proxy.py | 122 +++++ app/routers/@eaDir/__init__.py@SynoEAStream | Bin 0 -> 163 bytes app/routers/@eaDir/__pycache__@SynoEAStream | Bin 0 -> 163 bytes app/routers/@eaDir/api.py@SynoEAStream | Bin 0 -> 163 bytes app/routers/@eaDir/proxy.py@SynoEAStream | Bin 0 -> 163 bytes app/routers/__init__.py | 1 + app/routers/api.py | 95 ++++ app/routers/proxy.py | 19 + app/templates/@eaDir/base.html@SynoEAStream | Bin 0 -> 163 bytes app/templates/@eaDir/index.html@SynoEAStream | Bin 0 -> 163 bytes app/templates/base.html | 20 + app/templates/index.html | 148 ++++++ data/@eaDir/clones@SynoEAStream | Bin 0 -> 163 bytes data/@eaDir/personas@SynoEAStream | Bin 0 -> 163 bytes data/clones/.gitkeep | 0 data/personas/@eaDir/nabil.yaml@SynoEAStream | Bin 0 -> 163 bytes data/personas/@eaDir/nyora.yaml@SynoEAStream | Bin 0 -> 163 bytes data/personas/@eaDir/perso.yaml@SynoEAStream | Bin 0 -> 163 bytes data/personas/@eaDir/tt.yaml@SynoEAStream | Bin 0 -> 163 bytes data/personas/nabil.yaml | 17 + data/personas/nyora.yaml | 18 + data/personas/perso.yaml | 17 + data/personas/tt.yaml | 18 + docker-compose.yml | 26 ++ requirements.txt | 9 + static/@eaDir/css@SynoEAStream | Bin 0 -> 163 bytes static/@eaDir/js@SynoEAStream | Bin 0 -> 163 bytes static/css/@eaDir/style.css@SynoEAStream | Bin 0 -> 163 bytes static/css/@eaDir/tokens.css@SynoEAStream | Bin 0 -> 163 bytes static/css/style.css | 445 +++++++++++++++++++ static/css/tokens.css | 58 +++ static/js/@eaDir/hub.js@SynoEAStream | Bin 0 -> 163 bytes static/js/hub.js | 214 +++++++++ 59 files changed, 1538 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 @eaDir/.env.example@SynoEAStream create mode 100644 @eaDir/.git@SynoEAStream create mode 100644 @eaDir/.gitignore@SynoEAStream create mode 100644 @eaDir/Dockerfile@SynoEAStream create mode 100644 @eaDir/README.md@SynoEAStream create mode 100644 @eaDir/app@SynoEAStream create mode 100644 @eaDir/data@SynoEAStream create mode 100644 @eaDir/docker-compose.yml@SynoEAStream create mode 100644 @eaDir/requirements.txt@SynoEAStream create mode 100644 @eaDir/static@SynoEAStream create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/@eaDir/__init__.py@SynoEAStream create mode 100644 app/@eaDir/__pycache__@SynoEAStream create mode 100644 app/@eaDir/config.py@SynoEAStream create mode 100644 app/@eaDir/main.py@SynoEAStream create mode 100644 app/@eaDir/personas.py@SynoEAStream create mode 100644 app/@eaDir/proxy.py@SynoEAStream create mode 100644 app/@eaDir/routers@SynoEAStream create mode 100644 app/@eaDir/templates@SynoEAStream create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/personas.py create mode 100644 app/proxy.py create mode 100644 app/routers/@eaDir/__init__.py@SynoEAStream create mode 100644 app/routers/@eaDir/__pycache__@SynoEAStream create mode 100644 app/routers/@eaDir/api.py@SynoEAStream create mode 100644 app/routers/@eaDir/proxy.py@SynoEAStream create mode 100644 app/routers/__init__.py create mode 100644 app/routers/api.py create mode 100644 app/routers/proxy.py create mode 100644 app/templates/@eaDir/base.html@SynoEAStream create mode 100644 app/templates/@eaDir/index.html@SynoEAStream create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 data/@eaDir/clones@SynoEAStream create mode 100644 data/@eaDir/personas@SynoEAStream create mode 100644 data/clones/.gitkeep create mode 100644 data/personas/@eaDir/nabil.yaml@SynoEAStream create mode 100644 data/personas/@eaDir/nyora.yaml@SynoEAStream create mode 100644 data/personas/@eaDir/perso.yaml@SynoEAStream create mode 100644 data/personas/@eaDir/tt.yaml@SynoEAStream create mode 100644 data/personas/nabil.yaml create mode 100644 data/personas/nyora.yaml create mode 100644 data/personas/perso.yaml create mode 100644 data/personas/tt.yaml create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 static/@eaDir/css@SynoEAStream create mode 100644 static/@eaDir/js@SynoEAStream create mode 100644 static/css/@eaDir/style.css@SynoEAStream create mode 100644 static/css/@eaDir/tokens.css@SynoEAStream create mode 100644 static/css/style.css create mode 100644 static/css/tokens.css create mode 100644 static/js/@eaDir/hub.js@SynoEAStream create mode 100644 static/js/hub.js 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 0000000000000000000000000000000000000000..1ca67288b7db96eab0a81071a284a1f896d5bcea GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K/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 0000000000000000000000000000000000000000..1ca67288b7db96eab0a81071a284a1f896d5bcea GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K 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 0000000000000000000000000000000000000000..1ca67288b7db96eab0a81071a284a1f896d5bcea GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K + + + + + {% 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 0000000000000000000000000000000000000000..1ca67288b7db96eab0a81071a284a1f896d5bcea GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K=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 0000000000000000000000000000000000000000..1ca67288b7db96eab0a81071a284a1f896d5bcea GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K 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); + }); +})();