From c040c173df232c980d6efa2f8e8848177e8645cd Mon Sep 17 00:00:00 2001 From: bolbol Date: Thu, 20 Aug 2026 23:08:06 +0100 Subject: [PATCH] feat(chat): implement native hub chat with sqlite conversations store, SSE streaming and DSH 5th universe integration --- app/chat_service.py | 334 +++++++++++++ app/config.py | 39 +- app/db.py | 117 +++++ app/main.py | 12 +- app/proxy.py | 66 +-- app/routers/chat.py | 76 +++ app/templates/index.html | 107 ++++- data/personas/dsh.yaml | 16 + static/css/style.css | 906 +++++++++++++++++++++++++---------- static/css/tokens.css | 4 + static/js/hub.js | 985 ++++++++++++++++++++++++++++----------- 11 files changed, 2090 insertions(+), 572 deletions(-) create mode 100644 app/chat_service.py create mode 100644 app/db.py create mode 100644 app/routers/chat.py create mode 100644 data/personas/dsh.yaml diff --git a/app/chat_service.py b/app/chat_service.py new file mode 100644 index 0000000..8974d32 --- /dev/null +++ b/app/chat_service.py @@ -0,0 +1,334 @@ +import json +import uuid +import time +import asyncio +import httpx +import websockets +from typing import Dict, Any, List, Optional, AsyncGenerator +from app.config import UNIVERSES, settings +from app.db import ( + list_conversations, + get_conversation, + save_conversation, + update_conversation, + delete_conversation_record +) + +# Auth token & cookie cache +_AUTH_COOKIES: Dict[str, Dict[str, Any]] = {} + +async def get_workspace_cookie(universe_id: str) -> str: + """Authenticates to hermes-workspace (TT/Nyora/Perso) and caches the session cookie.""" + now = time.time() + cached = _AUTH_COOKIES.get(universe_id) + if cached and cached.get("expires_at", 0) > now + 60: + return cached["cookie"] + + universe = UNIVERSES.get(universe_id) + if not universe: + raise ValueError(f"Unknown universe {universe_id}") + + password = "" + if universe_id == "tt": + password = settings.hermes_tt_password + elif universe_id == "nyora": + password = settings.hermes_nyora_password + elif universe_id == "perso": + password = settings.hermes_perso_password + + auth_url = f"{universe.backend_url}/api/auth" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(auth_url, json={"password": password}) + if resp.status_code != 200: + raise RuntimeError(f"Failed to authenticate with {universe_id}: {resp.status_code} {resp.text}") + + cookie_header = resp.headers.get("set-cookie", "") + # Extract claude-auth cookie + token_part = "" + for part in cookie_header.split(";"): + if "claude-auth=" in part: + token_part = part.strip() + break + if not token_part and "claude-auth=" in cookie_header: + token_part = cookie_header.split(";")[0].strip() + + cookie_str = token_part or cookie_header.split(";")[0].strip() + _AUTH_COOKIES[universe_id] = { + "cookie": cookie_str, + "expires_at": now + (25 * 86400) # 25 days validity + } + return cookie_str + +async def get_nabil_session_cookie() -> str: + """Authenticates to hermes-nabil and caches JWT session cookies.""" + now = time.time() + cached = _AUTH_COOKIES.get("nabil") + if cached and cached.get("expires_at", 0) > now + 60: + return cached["cookie"] + + universe = UNIVERSES.get("nabil") + login_url = f"{universe.backend_url}/auth/password-login" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(login_url, json={ + "provider": "basic", + "username": settings.hermes_nabil_username, + "password": settings.hermes_nabil_password + }) + if resp.status_code != 200: + raise RuntimeError(f"Failed to authenticate with Nabil: {resp.status_code} {resp.text}") + + cookies_list = resp.headers.get_list("set-cookie") if hasattr(resp.headers, "get_list") else [resp.headers.get("set-cookie", "")] + cookie_parts = [] + for c in cookies_list: + if c: + cookie_parts.append(c.split(";")[0].strip()) + cookie_str = "; ".join(cookie_parts) + _AUTH_COOKIES["nabil"] = { + "cookie": cookie_str, + "expires_at": now + 3600 # 1 hour + } + return cookie_str + +async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]: + """Lists conversations for a given universe.""" + if universe_id == "nabil": + # Sync from Nabil backend and merge with local SQLite store + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=6.0) as client: + resp = await client.get("http://hermes-nabil:9119/api/sessions?limit=50", headers={"Cookie": cookie}) + if resp.status_code == 200: + data = resp.json() + backend_sessions = data.get("sessions", []) + for s in backend_sessions: + sid = str(s.get("id") or s.get("session_id") or "") + if sid: + stitle = s.get("title") or s.get("display_name") or f"Session {sid[:8]}" + spinned = 1 if s.get("pinned") else 0 + started_at = s.get("started_at") + c_at = int(started_at * 1000) if isinstance(started_at, (int, float)) and started_at < 1e11 else int(time.time() * 1000) + save_conversation("nabil", sid, title=stitle, pinned=spinned, created_at=c_at) + except Exception as e: + # Fallback to local store if backend unreachable + pass + return list_conversations("nabil") + elif universe_id in ("tt", "nyora", "perso"): + # For workspace backends, SQLite is the canonical index + return list_conversations(universe_id) + else: + return [] + +async def create_universe_conversation(universe_id: str, title: Optional[str] = None) -> Dict[str, Any]: + """Creates a new conversation in the universe and indexes it in Hub SQLite.""" + if universe_id in ("tt", "nyora", "perso"): + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.post(f"{universe.backend_url}/api/sessions", json={}, headers={"Cookie": cookie}) + if resp.status_code in (200, 201): + data = resp.json() + session_key = data.get("sessionKey") or data.get("friendlyId") or str(uuid.uuid4()) + else: + session_key = str(uuid.uuid4()) + + conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation") + return conv + elif universe_id == "nabil": + session_key = str(uuid.uuid4()) + conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation") + return conv + else: + raise ValueError(f"Unknown universe {universe_id}") + +async def get_conversation_messages(universe_id: str, session_key: str) -> List[Dict[str, Any]]: + """Fetches full message history for a conversation.""" + if universe_id in ("tt", "nyora", "perso"): + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get( + f"{universe.backend_url}/api/session-history?key={session_key}&limit=200", + headers={"Cookie": cookie} + ) + if resp.status_code == 200: + data = resp.json() + raw_messages = data.get("messages", []) + normalized = [] + for m in raw_messages: + normalized.append({ + "id": str(m.get("id", "")), + "role": m.get("role", "assistant"), + "content": m.get("content", ""), + "timestamp": m.get("timestamp", int(time.time() * 1000)) + }) + return normalized + return [] + elif universe_id == "nabil": + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get( + f"http://hermes-nabil:9119/api/sessions/{session_key}/messages?limit=200", + headers={"Cookie": cookie} + ) + if resp.status_code == 200: + data = resp.json() + raw_messages = data.get("messages", []) + normalized = [] + for m in raw_messages: + content = m.get("content", "") + # If assistant only has tool calls, present readable summary + if not content and m.get("tool_calls"): + tool_names = [tc.get("function", {}).get("name", "tool") for tc in m.get("tool_calls", [])] + content = f"[Outils exécutés : {', '.join(tool_names)}]" + normalized.append({ + "id": str(m.get("id", "")), + "role": m.get("role", "assistant"), + "content": content, + "reasoning": m.get("reasoning") or m.get("reasoning_content"), + "timestamp": int((m.get("timestamp") or time.time()) * 1000) + }) + return normalized + return [] + return [] + +async def stream_chat_messages(universe_id: str, session_key: str, message: str) -> AsyncGenerator[str, None]: + """Streams chat chunks using Server-Sent Events (SSE).""" + # Touch conversation in SQLite and update title if first message + existing = get_conversation(universe_id, session_key) + if existing and existing.get("title") in ("Nouvelle conversation", "Untitled", None): + summary_title = (message[:38] + "...") if len(message) > 38 else message + update_conversation(universe_id, session_key, title=summary_title) + + if universe_id in ("tt", "nyora", "perso"): + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream( + "POST", + f"{universe.backend_url}/api/send-stream", + json={"sessionKey": session_key, "message": message}, + headers={"Cookie": cookie, "Content-Type": "application/json"} + ) as response: + async for line in response.aiter_lines(): + if line: + yield f"{line}\n" + else: + yield "\n" + # Touch updated_at + save_conversation(universe_id, session_key) + + elif universe_id == "nabil": + cookie = await get_nabil_session_cookie() + # Acquire ws ticket + async with httpx.AsyncClient(timeout=8.0) as client: + resp_ticket = await client.post("http://hermes-nabil:9119/api/auth/ws-ticket", json={}, headers={"Cookie": cookie}) + ticket = resp_ticket.json().get("ticket") if resp_ticket.status_code == 200 else "" + + yield "event: started\ndata: {\"status\": \"connecting\"}\n\n" + + ws_url = f"ws://hermes-nabil:9119/api/ws?ticket={ticket}" + try: + async with websockets.connect( + ws_url, + additional_headers={"Cookie": cookie} + ) as ws: + # Send chat message via JSON-RPC + req_id = str(uuid.uuid4()) + send_payload = { + "jsonrpc": "2.0", + "id": req_id, + "method": "chat.send", + "params": { + "session_id": session_key, + "message": message + } + } + await ws.send(json.dumps(send_payload)) + + accumulated_text = "" + while True: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=45.0) + data = json.loads(raw) + + method = data.get("method") + params = data.get("params", {}) + + if method == "chat.chunk": + chunk_text = params.get("text", "") + accumulated_text += chunk_text + yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': chunk_text, 'fullReplace': True})}\n\n" + elif method in ("chat.done", "session.done", "gateway.ready"): + if method != "gateway.ready": + yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': accumulated_text})}\n\n" + break + elif data.get("id") == req_id and "result" in data: + res_text = data["result"].get("content") or data["result"].get("text") or accumulated_text + yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': res_text})}\n\n" + break + except asyncio.TimeoutError: + break + except Exception as e: + yield f"event: chunk\ndata: {json.dumps({'text': f'Erreur de communication Nabil: {str(e)}', 'fullReplace': True})}\n\n" + yield f"event: done\ndata: {json.dumps({'state': 'error'})}\n\n" + + save_conversation(universe_id, session_key) + +async def rename_universe_conversation(universe_id: str, session_key: str, title: str) -> bool: + """Renames a conversation in SQLite and on Nabil backend if applicable.""" + update_conversation(universe_id, session_key, title=title) + if universe_id == "nabil": + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.patch( + f"http://hermes-nabil:9119/api/sessions/{session_key}", + json={"title": title}, + headers={"Cookie": cookie} + ) + except Exception: + pass + return True + +async def pin_universe_conversation(universe_id: str, session_key: str, pinned: bool) -> bool: + """Pins/unpins a conversation in SQLite and on Nabil backend if applicable.""" + update_conversation(universe_id, session_key, pinned=pinned) + if universe_id == "nabil": + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.patch( + f"http://hermes-nabil:9119/api/sessions/{session_key}", + json={"pinned": pinned}, + headers={"Cookie": cookie} + ) + except Exception: + pass + return True + +async def delete_universe_conversation(universe_id: str, session_key: str) -> bool: + """Deletes a conversation from SQLite and from backend if supported.""" + delete_conversation_record(universe_id, session_key) + if universe_id in ("tt", "nyora", "perso"): + try: + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + async with httpx.AsyncClient(timeout=5.0) as client: + await client.delete( + f"{universe.backend_url}/api/sessions?sessionKey={session_key}", + headers={"Cookie": cookie} + ) + except Exception: + pass + elif universe_id == "nabil": + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.delete( + f"http://hermes-nabil:9119/api/sessions/{session_key}", + headers={"Cookie": cookie} + ) + except Exception: + pass + return True diff --git a/app/config.py b/app/config.py index 8a67021..8937606 100644 --- a/app/config.py +++ b/app/config.py @@ -8,6 +8,7 @@ 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" +DB_PATH = DATA_DIR / "hub.db" class UniverseConfig(BaseModel): id: str @@ -22,6 +23,7 @@ class UniverseConfig(BaseModel): enabled: bool = True supports_files: bool = False files_url: Optional[str] = None + is_external_app: bool = False class Settings(BaseSettings): app_name: str = "Hermes Hub" @@ -38,8 +40,16 @@ class Settings(BaseSettings): 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://hermes-nabil:9119") + hermes_dsh_url: str = os.getenv("HERMES_DSH_URL", "http://dsh-vps:3080") dsh_filebrowser_url: str = os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080") + # Backend Passwords + hermes_tt_password: str = os.getenv("HERMES_TT_PASSWORD", "XiEdCtyWETbzpQ7dxrRyvAYu") + hermes_nyora_password: str = os.getenv("HERMES_NYORA_PASSWORD", "juoKPfPGuo39wKCn9WJ0kY0_") + hermes_perso_password: str = os.getenv("HERMES_PERSO_PASSWORD", "-6Q12oViKsgZgIL82Kspa_dV") + hermes_nabil_username: str = os.getenv("HERMES_NABIL_USERNAME", "nabil") + hermes_nabil_password: str = os.getenv("HERMES_NABIL_PASSWORD", "NabilMasterHermes2026!") + # Hub Secret — OBLIGATOIRE, aucun fallback codé en dur (Fail-Fast au démarrage) hub_secret: str = Field(..., min_length=16, description="Clé secrète maîtresse requise pour Hermes Hub") @@ -72,7 +82,8 @@ UNIVERSES: Dict[str, UniverseConfig] = { accent_token="--accent-tt", icon="briefcase", persona_file="tt.yaml", - supports_files=False + supports_files=False, + is_external_app=False ), "nyora": UniverseConfig( id="nyora", @@ -84,7 +95,8 @@ UNIVERSES: Dict[str, UniverseConfig] = { accent_token="--accent-nyora", icon="sparkles", persona_file="nyora.yaml", - supports_files=False + supports_files=False, + is_external_app=False ), "perso": UniverseConfig( id="perso", @@ -96,20 +108,35 @@ UNIVERSES: Dict[str, UniverseConfig] = { accent_token="--accent-perso", icon="home", persona_file="perso.yaml", - supports_files=False + supports_files=False, + is_external_app=False ), "nabil": UniverseConfig( id="nabil", name="Nabil Master", - tagline="Orchestration & DSH", - description="Master Agent VPS, exécution de code & DeepSeek Harness", + tagline="Orchestration & Code", + description="Master Agent VPS, exécution de code & supervision DSH", backend_url=os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119"), scope="nabil", accent_token="--accent-nabil", icon="terminal", persona_file="nabil.yaml", supports_files=True, - files_url=os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080") + files_url=os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080"), + is_external_app=False + ), + "dsh": UniverseConfig( + id="dsh", + name="DeepSeek Harness", + tagline="DSH Autonomous Agent", + description="Plateforme DeepSeek Harness : agents autonomes, sous-agents, trajectoires et tâches", + backend_url=os.getenv("HERMES_DSH_URL", "http://dsh-vps:3080"), + scope="dsh", + accent_token="--accent-dsh", + icon="cpu", + persona_file="dsh.yaml", + supports_files=False, + is_external_app=True ) } diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..4e84529 --- /dev/null +++ b/app/db.py @@ -0,0 +1,117 @@ +import sqlite3 +import time +from pathlib import Path +from typing import List, Dict, Any, Optional +from app.config import DB_PATH, DATA_DIR + +def init_db(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS hub_conversations ( + universe_id TEXT NOT NULL, + session_key TEXT NOT NULL, + title TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (universe_id, session_key) + ) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_hub_conv_universe + ON hub_conversations(universe_id, pinned DESC, updated_at DESC) + """) + conn.commit() + +def list_conversations(universe_id: str) -> List[Dict[str, Any]]: + init_db() + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(""" + SELECT universe_id, session_key, title, created_at, updated_at, pinned + FROM hub_conversations + WHERE universe_id = ? + ORDER BY pinned DESC, updated_at DESC + """, (universe_id,)) + rows = cursor.fetchall() + return [dict(r) for r in rows] + +def get_conversation(universe_id: str, session_key: str) -> Optional[Dict[str, Any]]: + init_db() + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(""" + SELECT universe_id, session_key, title, created_at, updated_at, pinned + FROM hub_conversations + WHERE universe_id = ? AND session_key = ? + """, (universe_id, session_key)) + row = cursor.fetchone() + return dict(row) if row else None + +def save_conversation( + universe_id: str, + session_key: str, + title: Optional[str] = None, + pinned: Optional[int] = None, + created_at: Optional[int] = None, + updated_at: Optional[int] = None +) -> Dict[str, Any]: + init_db() + now = int(time.time() * 1000) + c_at = created_at or now + u_at = updated_at or now + p_val = 1 if pinned else 0 + t_val = title or "Nouvelle conversation" + + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO hub_conversations (universe_id, session_key, title, created_at, updated_at, pinned) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(universe_id, session_key) DO UPDATE SET + title = COALESCE(excluded.title, hub_conversations.title), + pinned = COALESCE(excluded.pinned, hub_conversations.pinned), + updated_at = excluded.updated_at + """, (universe_id, session_key, t_val, c_at, u_at, p_val)) + conn.commit() + return get_conversation(universe_id, session_key) + +def update_conversation( + universe_id: str, + session_key: str, + title: Optional[str] = None, + pinned: Optional[bool] = None +) -> Optional[Dict[str, Any]]: + init_db() + existing = get_conversation(universe_id, session_key) + if not existing: + return None + + now = int(time.time() * 1000) + new_title = title if title is not None else existing["title"] + new_pinned = (1 if pinned else 0) if pinned is not None else existing["pinned"] + + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE hub_conversations + SET title = ?, pinned = ?, updated_at = ? + WHERE universe_id = ? AND session_key = ? + """, (new_title, new_pinned, now, universe_id, session_key)) + conn.commit() + return get_conversation(universe_id, session_key) + +def delete_conversation_record(universe_id: str, session_key: str) -> bool: + init_db() + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + DELETE FROM hub_conversations + WHERE universe_id = ? AND session_key = ? + """, (universe_id, session_key)) + conn.commit() + return cursor.rowcount > 0 diff --git a/app/main.py b/app/main.py index ef8e79e..244b960 100644 --- a/app/main.py +++ b/app/main.py @@ -7,9 +7,10 @@ from typing import Optional, Tuple from contextlib import asynccontextmanager from app.config import settings, UNIVERSES, BASE_DIR -from app.routers import api, proxy +from app.routers import api, proxy, chat from app.proxy import close_http_client, proxy_request, proxy_websocket from app.personas import ensure_dirs +from app.db import init_db def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: """ @@ -20,7 +21,8 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: nyora-hub.yesminedor.tn -> (HERMES_NYORA_URL, 'nyora') perso-hub.yesminedor.tn -> (HERMES_PERSO_URL, 'perso') nabil-hub.yesminedor.tn -> (HERMES_NABIL_URL, 'nabil') - dsh-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'nabil') + dsh-hub.yesminedor.tn -> (HERMES_DSH_URL, 'dsh') + files-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'nabil') Apex / Hub UI: hub.yesminedor.tn -> None (Serves index.html Workspace Switcher) @@ -51,9 +53,9 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: if not sub or sub in ("hub", "www"): return None - if sub in ("dsh", "files", "dsh-files"): + if sub in ("files", "dsh-files"): return (settings.dsh_filebrowser_url, "nabil") - + if sub in UNIVERSES: return (UNIVERSES[sub].backend_url, sub) @@ -62,6 +64,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: @asynccontextmanager async def lifespan(app: FastAPI): ensure_dirs() + init_db() yield await close_http_client() @@ -99,6 +102,7 @@ templates = Jinja2Templates(directory=str(templates_dir)) # Include Routers app.include_router(api.router) app.include_router(proxy.router) +app.include_router(chat.router) @app.get("/", response_class=HTMLResponse) @app.head("/", response_class=HTMLResponse) diff --git a/app/proxy.py b/app/proxy.py index c9302f4..239980d 100644 --- a/app/proxy.py +++ b/app/proxy.py @@ -51,10 +51,6 @@ async def close_http_client(): _http_transport = None def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str: - """ - Rewrites the Path attribute of a Set-Cookie header to /u/{universe_id}/ - when using path-based proxying. - """ target_path = f"/u/{universe_id}/" if re.search(r'(?i)\bpath=[^;]*', cookie_header): return re.sub(r'(?i)\bpath=[^;]*', f'Path={target_path}', cookie_header) @@ -95,18 +91,27 @@ async def proxy_request( if request.url.query: target_url = f"{target_url}?{request.url.query}" + is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh" + req_headers: List[Tuple[bytes, bytes]] = [] for raw_k, raw_v in request.headers.raw: k_str = raw_k.decode("latin-1").lower() - if k_str not in HOP_BY_HOP_HEADERS and k_str != "host": - req_headers.append((raw_k, raw_v)) + if k_str in HOP_BY_HOP_HEADERS or k_str == "host": + continue + if is_dsh and k_str == "origin": + continue + req_headers.append((raw_k, raw_v)) - # Forward original host header info - req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1"))) + # If DSH backend, replicate localhost:3080 Host/Origin for internal origin check + if is_dsh: + req_headers.append((b"host", b"localhost:3080")) + req_headers.append((b"origin", b"http://localhost:3080")) + else: + req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1"))) + req_headers.append((b"x-forwarded-proto", (request.url.scheme or "http").encode("latin-1"))) body = await request.body() - is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}") try: @@ -117,10 +122,8 @@ async def proxy_request( content=body ) - # Pure stateless async request execution — NO cookie jar retention upstream_res = await transport.handle_async_request(upstream_req) - # Build raw headers list for precise multi-header control raw_headers: List[Tuple[bytes, bytes]] = [] media_type = upstream_res.headers.get("content-type") @@ -132,11 +135,9 @@ async def proxy_request( continue if k_str == "set-cookie" and is_path_proxied: - # Path-based proxying: rewrite cookie path v_str = rewrite_cookie_path(v_str, universe_id) raw_headers.append((b"set-cookie", v_str.encode("latin-1"))) elif k_str == "location" and is_path_proxied: - # Path-based proxying: rewrite location redirect if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"): v_str = f"/u/{universe_id}{v_str}" raw_headers.append((b"location", v_str.encode("latin-1"))) @@ -161,7 +162,7 @@ async def proxy_request( 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.

", + content=f"Backend Unavailable

Instance Hermes inaccessible

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

", status_code=502, media_type="text/html" ) @@ -179,10 +180,6 @@ async def proxy_websocket( path: str, universe_id: Optional[str] = None ): - """ - Bi-directional full duplex WebSocket proxy bridge. - Forwards connection from client browser to backend Hermes universe instance. - """ ws_base = backend_url.replace("https://", "wss://").replace("http://", "ws://").rstrip("/") sub_path = path.lstrip("/") upstream_url = f"{ws_base}/{sub_path}" if sub_path else ws_base @@ -190,30 +187,39 @@ async def proxy_websocket( if client_ws.url.query: upstream_url = f"{upstream_url}?{client_ws.url.query}" + is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh" + upstream_headers = {} for k, v in client_ws.headers.items(): if k.lower() not in WS_HOP_BY_HOP_HEADERS: upstream_headers[k] = v - upstream_headers["x-forwarded-host"] = client_ws.headers.get("host", "") - upstream_headers["x-forwarded-proto"] = client_ws.url.scheme or "http" + if is_dsh: + upstream_headers["host"] = "localhost:3080" + origin_val = "http://localhost:3080" + else: + upstream_headers["x-forwarded-host"] = client_ws.headers.get("host", "") + upstream_headers["x-forwarded-proto"] = client_ws.url.scheme or "http" + origin_val = None + if client_ws.client: upstream_headers["x-forwarded-for"] = client_ws.client.host - # Check subprotocols subprotocols_raw = client_ws.headers.get("sec-websocket-protocol", "") subprotocols = [s.strip() for s in subprotocols_raw.split(",") if s.strip()] or None try: - async with websockets.connect( - upstream_url, - additional_headers=upstream_headers, - subprotocols=subprotocols, - ping_interval=20, - ping_timeout=20, - max_size=10 * 1024 * 1024 - ) as upstream_ws: - # Accept client connection with negotiated subprotocol + connect_kwargs = { + "additional_headers": upstream_headers, + "subprotocols": subprotocols, + "ping_interval": 20, + "ping_timeout": 20, + "max_size": 10 * 1024 * 1024 + } + if origin_val: + connect_kwargs["origin"] = origin_val + + async with websockets.connect(upstream_url, **connect_kwargs) as upstream_ws: await client_ws.accept(subprotocol=upstream_ws.subprotocol) async def client_to_upstream(): diff --git a/app/routers/chat.py b/app/routers/chat.py new file mode 100644 index 0000000..0d5a4d6 --- /dev/null +++ b/app/routers/chat.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, HTTPException, Query, Body, Request +from fastapi.responses import StreamingResponse +from typing import Optional, Dict, Any, List +from pydantic import BaseModel + +from app.chat_service import ( + list_universe_conversations, + create_universe_conversation, + get_conversation_messages, + stream_chat_messages, + rename_universe_conversation, + pin_universe_conversation, + delete_universe_conversation +) + +router = APIRouter(prefix="/api/chat", tags=["Chat"]) + +class CreateConversationRequest(BaseModel): + title: Optional[str] = "Nouvelle conversation" + +class UpdateConversationRequest(BaseModel): + title: Optional[str] = None + pinned: Optional[bool] = None + +class SendMessageRequest(BaseModel): + message: str + +@router.get("/{universe_id}/conversations") +async def get_conversations(universe_id: str): + """Returns the list of conversations for a universe.""" + convs = await list_universe_conversations(universe_id) + return {"ok": True, "universe_id": universe_id, "conversations": convs} + +@router.post("/{universe_id}/conversations") +async def create_conversation(universe_id: str, body: CreateConversationRequest = Body(default_factory=CreateConversationRequest)): + """Creates a new conversation in a universe.""" + conv = await create_universe_conversation(universe_id, title=body.title) + return {"ok": True, "conversation": conv} + +@router.get("/{universe_id}/conversations/{session_key}/messages") +async def get_messages(universe_id: str, session_key: str): + """Retrieves full message history for a conversation.""" + messages = await get_conversation_messages(universe_id, session_key) + return {"ok": True, "session_key": session_key, "messages": messages} + +@router.post("/{universe_id}/conversations/{session_key}/send") +async def send_message(universe_id: str, session_key: str, body: SendMessageRequest): + """Sends a message and streams SSE chunks back.""" + if not body.message or not body.message.strip(): + raise HTTPException(status_code=400, detail="Message cannot be empty") + + stream = stream_chat_messages(universe_id, session_key, body.message.strip()) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + +@router.patch("/{universe_id}/conversations/{session_key}") +async def update_conv(universe_id: str, session_key: str, body: UpdateConversationRequest): + """Updates conversation title or pinned status.""" + if body.title is not None: + await rename_universe_conversation(universe_id, session_key, body.title) + if body.pinned is not None: + await pin_universe_conversation(universe_id, session_key, body.pinned) + return {"ok": True} + +@router.delete("/{universe_id}/conversations/{session_key}") +async def delete_conv(universe_id: str, session_key: str): + """Deletes a conversation.""" + await delete_universe_conversation(universe_id, session_key) + return {"ok": True} diff --git a/app/templates/index.html b/app/templates/index.html index 41a51ee..683bc70 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -8,7 +8,7 @@
H
Hermes Hub
-
v1.0
+
v2.0
- Session sécurisée VPS ↔ NAS + Session Cloudflare Access Active - +
- -
-
-

Connexion à l'instance Hermes...

+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
Chargement des conversations...
+
+
+ + +
+ +
+
+ Nouvelle conversation + Prêt +
+
+ + + +
+
+ + +
+
+
💬
+

Discussion avec Hermes

+

Posez une question ou donnez une instruction pour démarrer la session.

+
+
+ + +
+
+ + + +
+
Hermes Agent Hub • Réponse en streaming direct
+
+
- - + +
diff --git a/data/personas/dsh.yaml b/data/personas/dsh.yaml new file mode 100644 index 0000000..981fd4d --- /dev/null +++ b/data/personas/dsh.yaml @@ -0,0 +1,16 @@ +universe_id: dsh +name: DeepSeek Harness +tagline: Agent Autonome & Trajectoires DSH +tone: Technique, précis, orienté exécution +style: Code, raisonnement pas-à-pas, logs de sous-agents +principles: + - Exécution autonome des sous-agents et vérification stricte + - Gestion des tâches complexes par décomposition + - Supervision des artefacts et fichiers générés +system_prompt: | + Tu es l'environnement DeepSeek Harness (DSH). + Tu pilotes les sous-agents autonomes, les trajectoires d'exécution et les flux de tâches. +skills_active: + - dsh-agents + - code-execution +version: 1 diff --git a/static/css/style.css b/static/css/style.css index 80196b5..adeb4cf 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,108 +1,106 @@ -/* Reset & Base Setup */ -*, *::before, *::after { +/** + * Hermes Hub — Global Layout & Native Chat Styles + */ + +* { box-sizing: border-box; margin: 0; padding: 0; } -html, body { - width: 100%; +body, html { height: 100%; - overflow: hidden; + width: 100%; + font-family: var(--hub-font-sans); background-color: var(--hub-bg-canvas); color: var(--hub-text-primary); - font-family: var(--hub-font-sans); + overflow: hidden; -webkit-font-smoothing: antialiased; } -/* App Shell Container */ +/* Shell Layout */ .hub-shell { display: flex; - width: 100vw; height: 100vh; + width: 100vw; overflow: hidden; } -/* Sidebar / Workspace Switcher */ +/* Primary Sidebar */ .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; + flex-shrink: 0; z-index: 20; - transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; } .hub-brand { height: var(--hub-header-height); - padding: 0 0.85rem 0 1.25rem; + padding: 0 1rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hub-border-subtle); - overflow: hidden; + gap: 0.5rem; } .hub-brand-left { display: flex; align-items: center; - gap: 0.75rem; + gap: 0.625rem; overflow: hidden; + white-space: nowrap; } .hub-logo-icon { - width: 32px; - height: 32px; - border-radius: var(--hub-radius-md); - background: linear-gradient(135deg, var(--hub-accent-current), #4f46e5); + width: 28px; + height: 28px; + border-radius: var(--hub-radius-sm); + background: linear-gradient(135deg, var(--hub-accent-current), #1e293b); display: flex; align-items: center; justify-content: center; font-weight: 700; - font-size: 1rem; + font-size: 0.9rem; color: #ffffff; - box-shadow: 0 0 12px var(--hub-accent-glow-current); + box-shadow: 0 2px 8px var(--hub-accent-glow-current); flex-shrink: 0; } .hub-brand-text { - font-size: 1.05rem; font-weight: 600; + font-size: 1rem; letter-spacing: -0.01em; color: var(--hub-text-primary); - white-space: nowrap; } .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); + background-color: var(--hub-bg-active); color: var(--hub-text-secondary); - border: 1px solid var(--hub-border-subtle); - white-space: nowrap; + border-radius: var(--hub-radius-full); + font-weight: 500; } -/* Sidebar Collapse Toggle Button */ .hub-collapse-btn { - width: 28px; - height: 28px; - border-radius: var(--hub-radius-sm); background: transparent; border: 1px solid transparent; color: var(--hub-text-muted); + width: 28px; + height: 28px; + border-radius: var(--hub-radius-sm); display: flex; align-items: center; justify-content: center; cursor: pointer; - transition: all 0.15s ease; flex-shrink: 0; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; } .hub-collapse-btn:hover { @@ -112,18 +110,16 @@ html, body { } .hub-collapse-icon { - transition: transform 0.2s ease; + transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); } -/* Universes Switcher List */ .hub-nav-section { - padding: 1rem 0.75rem 0.5rem; + padding: 1.25rem 1rem 0.5rem; font-size: 0.7rem; - font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; color: var(--hub-text-muted); - white-space: nowrap; + font-weight: 600; } .hub-universes-list { @@ -131,22 +127,20 @@ html, body { padding: 0 0.5rem; display: flex; flex-direction: column; - gap: 0.35rem; + gap: 0.25rem; flex: 1; overflow-y: auto; - overflow-x: hidden; } .hub-universe-btn { width: 100%; + background: transparent; + border: 1px solid transparent; + padding: 0.5rem 0.625rem; + border-radius: var(--hub-radius-md); 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; @@ -155,108 +149,109 @@ html, body { .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); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); } .hub-universe-btn.active::before { - content: ""; + 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)); + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 20px; + background-color: var(--item-accent); + border-radius: 0 4px 4px 0; + box-shadow: 0 0 8px var(--item-accent); } .hub-universe-avatar { width: 32px; height: 32px; - border-radius: var(--hub-radius-sm); + border-radius: var(--hub-radius-md); + background-color: var(--hub-bg-active); + color: var(--hub-text-primary); + font-size: 0.75rem; + font-weight: 700; 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; + transition: transform 0.15s ease, border-color 0.15s ease; } .hub-universe-btn.active .hub-universe-avatar { - background-color: var(--item-accent, var(--hub-accent-current)); - color: #ffffff; - border-color: transparent; + border-color: var(--item-accent); + box-shadow: 0 0 8px var(--item-accent); } .hub-universe-info { - display: flex; - flex-direction: column; - overflow: hidden; flex: 1; + min-width: 0; } .hub-universe-name { - font-size: 0.875rem; + font-size: 0.85rem; font-weight: 600; + color: var(--hub-text-primary); white-space: nowrap; - text-overflow: ellipsis; overflow: hidden; + text-overflow: ellipsis; } .hub-universe-tagline { - font-size: 0.725rem; + font-size: 0.7rem; color: var(--hub-text-muted); white-space: nowrap; - text-overflow: ellipsis; overflow: hidden; + text-overflow: ellipsis; } .hub-status-dot { width: 8px; height: 8px; border-radius: var(--hub-radius-full); - background-color: var(--hub-status-offline); + background-color: var(--hub-status-degraded); flex-shrink: 0; } .hub-status-dot.online { background-color: var(--hub-status-online); - box-shadow: 0 0 6px rgba(16, 185, 129, 0.6); + box-shadow: 0 0 6px var(--hub-status-online); +} + +.hub-status-dot.offline { + background-color: var(--hub-status-offline); } -/* Sidebar Footer */ .hub-sidebar-footer { padding: 0.75rem; border-top: 1px solid var(--hub-border-subtle); display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.35rem; } .hub-tool-btn { - width: 100%; + background: transparent; + border: 1px solid transparent; + padding: 0.5rem 0.625rem; + border-radius: var(--hub-radius-sm); + color: var(--hub-text-secondary); + font-size: 0.75rem; 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; - white-space: nowrap; + width: 100%; + text-align: left; } .hub-tool-btn:hover { @@ -264,9 +259,7 @@ html, body { color: var(--hub-text-primary); } -/* ========================================================================= - Collapsed Sidebar State - ========================================================================= */ +/* Sidebar Collapsed State */ .hub-sidebar.collapsed { width: var(--hub-sidebar-collapsed-width); } @@ -276,16 +269,12 @@ html, body { justify-content: center; } -.hub-sidebar.collapsed .hub-brand-left, +.hub-sidebar.collapsed .hub-brand-text, +.hub-sidebar.collapsed .hub-brand-badge, .hub-sidebar.collapsed .hub-nav-section, .hub-sidebar.collapsed .hub-universe-info, -.hub-sidebar.collapsed .hub-sidebar-footer .hub-tool-btn span:last-child { - display: none; -} - -.hub-sidebar.collapsed .hub-collapse-btn { - width: 36px; - height: 36px; +.hub-sidebar.collapsed .hub-tool-btn span:last-child { + display: none !important; } .hub-sidebar.collapsed .hub-collapse-icon { @@ -293,12 +282,12 @@ html, body { } .hub-sidebar.collapsed .hub-universe-btn { - padding: 0.65rem 0; + padding: 0.5rem; justify-content: center; } -.hub-sidebar.collapsed .hub-universe-btn.active::before { - left: -0.25rem; +.hub-sidebar.collapsed .hub-universe-btn::before { + left: -0.5rem; } .hub-sidebar.collapsed .hub-status-dot { @@ -307,82 +296,594 @@ html, body { right: 6px; } -.hub-sidebar.collapsed .hub-sidebar-footer .hub-tool-btn { - padding: 0.5rem 0; +.hub-sidebar.collapsed .hub-tool-btn { justify-content: center; + padding: 0.5rem; } -/* Main View Area */ +/* Main Content Area */ .hub-main { flex: 1; display: flex; flex-direction: column; - height: 100%; - overflow: hidden; + min-width: 0; background-color: var(--hub-bg-canvas); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + height: 100%; } -/* Universe Topbar */ .hub-topbar { height: var(--hub-header-height); - background-color: var(--hub-bg-sidebar); - border-bottom: 1px solid var(--hub-border-subtle); + padding: 0 1.25rem; display: flex; align-items: center; justify-content: space-between; - padding: 0 1.25rem; - z-index: 10; + border-bottom: 1px solid var(--hub-border-subtle); + background-color: var(--hub-bg-sidebar); + flex-shrink: 0; + gap: 1rem; } .hub-topbar-left { display: flex; align-items: center; - gap: 1rem; + gap: 0.75rem; } .hub-active-title { font-size: 1rem; font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; + letter-spacing: -0.01em; } .hub-scope-pill { - font-size: 0.7rem; - font-weight: 600; + font-size: 0.65rem; padding: 2px 8px; + background-color: var(--hub-bg-active); + color: var(--hub-text-secondary); border-radius: var(--hub-radius-full); - background-color: var(--hub-bg-card); - border: 1px solid var(--hub-border-medium); - color: var(--hub-accent-current); + font-family: var(--hub-font-mono); + border: 1px solid var(--hub-border-subtle); } .hub-topbar-right { display: flex; align-items: center; - gap: 0.75rem; + gap: 1rem; } -/* View Canvas (Iframe Workspace Container) */ +.hub-mode-toggle { + display: inline-flex; + align-items: center; + background-color: var(--hub-bg-active); + border: 1px solid var(--hub-border-subtle); + border-radius: var(--hub-radius-md); + padding: 3px; + gap: 2px; +} + +.hub-mode-tab { + background: transparent; + border: none; + color: var(--hub-text-muted); + font-size: 0.75rem; + font-weight: 500; + padding: 4px 10px; + border-radius: var(--hub-radius-sm); + display: flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.hub-mode-tab:hover { + color: var(--hub-text-primary); +} + +.hub-mode-tab.active { + background-color: var(--hub-bg-card); + color: var(--hub-text-primary); + box-shadow: 0 1px 4px rgba(0,0,0,0.3); +} + +.hub-mode-divider { + width: 1px; + height: 12px; + background-color: var(--hub-border-medium); +} + +/* Canvas Area */ .hub-canvas { flex: 1; - width: 100%; - height: calc(100% - var(--hub-header-height)); + display: flex; position: relative; + overflow: hidden; + height: calc(100vh - var(--hub-header-height)); +} + +/* ========================================================================== + Native Hub Chat Two-Column Layout + ========================================================================== */ +.hub-native-chat-layout { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; +} + +/* Sub-sidebar Conversations Pane */ +.hub-conv-sidebar { + width: var(--hub-conv-sidebar-width); + background-color: var(--hub-bg-sidebar); + border-right: 1px solid var(--hub-border-subtle); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; +} + +.hub-conv-header { + padding: 0.75rem; + border-bottom: 1px solid var(--hub-border-subtle); +} + +.hub-btn-new-chat { + width: 100%; + background: linear-gradient(135deg, var(--hub-bg-card), var(--hub-bg-card-hover)); + border: 1px solid var(--hub-border-medium); + color: var(--hub-text-primary); + font-size: 0.825rem; + font-weight: 600; + padding: 0.6rem 0.75rem; + border-radius: var(--hub-radius-md); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.hub-btn-new-chat:hover { + border-color: var(--hub-accent-current); + box-shadow: 0 2px 10px var(--hub-accent-glow-current); + background: var(--hub-bg-card-hover); +} + +.hub-conv-search-wrap { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--hub-border-subtle); +} + +.hub-conv-search-input { + width: 100%; background-color: var(--hub-bg-canvas); + border: 1px solid var(--hub-border-subtle); + border-radius: var(--hub-radius-sm); + padding: 0.4rem 0.6rem; + color: var(--hub-text-primary); + font-size: 0.75rem; + outline: none; +} + +.hub-conv-search-input:focus { + border-color: var(--hub-accent-current); +} + +.hub-conv-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.hub-conv-group-title { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--hub-text-muted); + font-weight: 600; + padding: 0.5rem 0.5rem 0.25rem; +} + +.hub-conv-item { + padding: 0.55rem 0.65rem; + border-radius: var(--hub-radius-md); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.2rem; + position: relative; + transition: all 0.15s ease; +} + +.hub-conv-item:hover { + background-color: var(--hub-bg-card-hover); +} + +.hub-conv-item.active { + background-color: var(--hub-bg-card); + border-color: var(--hub-border-medium); +} + +.hub-conv-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.hub-conv-title { + font-size: 0.8rem; + font-weight: 500; + color: var(--hub-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.hub-conv-meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.65rem; + color: var(--hub-text-muted); +} + +.hub-conv-actions { + display: none; + align-items: center; + gap: 0.25rem; +} + +.hub-conv-item:hover .hub-conv-actions, +.hub-conv-item.active .hub-conv-actions { + display: flex; +} + +.hub-conv-action-btn { + background: transparent; + border: none; + color: var(--hub-text-muted); + cursor: pointer; + padding: 2px; + font-size: 0.75rem; + border-radius: 4px; + transition: color 0.1s ease; +} + +.hub-conv-action-btn:hover { + color: var(--hub-text-primary); +} + +/* Right Main Chat Pane */ +.hub-chat-pane { + flex: 1; + display: flex; + flex-direction: column; + background-color: var(--hub-bg-canvas); + height: 100%; + overflow: hidden; + position: relative; +} + +.hub-chat-header { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--hub-border-subtle); + background-color: var(--hub-bg-sidebar); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.hub-chat-header-title { + font-size: 0.9rem; + font-weight: 600; + color: var(--hub-text-primary); +} + +.hub-chat-header-status { + font-size: 0.7rem; + color: var(--hub-text-muted); + margin-left: 0.5rem; +} + +.hub-chat-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.hub-btn-icon { + background: var(--hub-bg-card); + border: 1px solid var(--hub-border-subtle); + color: var(--hub-text-secondary); + width: 28px; + height: 28px; + border-radius: var(--hub-radius-sm); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.8rem; + transition: all 0.15s ease; +} + +.hub-btn-icon:hover { + background-color: var(--hub-bg-card-hover); + color: var(--hub-text-primary); + border-color: var(--hub-border-medium); +} + +.hub-btn-danger:hover { + color: #ef4444; + border-color: #ef4444; +} + +/* Messages Scroll View */ +.hub-messages-container { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + scroll-behavior: smooth; +} + +.hub-empty-state { + margin: auto; + text-align: center; + max-width: 360px; + color: var(--hub-text-muted); +} + +.hub-empty-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.hub-empty-state h3 { + color: var(--hub-text-primary); + font-size: 1.1rem; + margin-bottom: 0.5rem; +} + +.hub-empty-state p { + font-size: 0.85rem; + line-height: 1.5; +} + +/* Message Rows */ +.hub-msg { + display: flex; + gap: 0.75rem; + max-width: 820px; + width: 100%; + margin: 0 auto; +} + +.hub-msg-user { + flex-direction: row-reverse; +} + +.hub-msg-avatar { + width: 32px; + height: 32px; + border-radius: var(--hub-radius-md); + background-color: var(--hub-bg-card); + border: 1px solid var(--hub-border-medium); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; +} + +.hub-msg-user .hub-msg-avatar { + background-color: var(--hub-accent-current); + color: #ffffff; + border: none; +} + +.hub-msg-bubble { + padding: 0.85rem 1.1rem; + border-radius: var(--hub-radius-lg); + font-size: 0.875rem; + line-height: 1.6; + max-width: calc(100% - 48px); + word-break: break-word; +} + +.hub-msg-user .hub-msg-bubble { + background: linear-gradient(135deg, var(--hub-accent-current), #1d4ed8); + color: #ffffff; + border-bottom-right-radius: 4px; +} + +.hub-msg-assistant .hub-msg-bubble { + background-color: var(--hub-bg-card); + border: 1px solid var(--hub-border-subtle); + color: var(--hub-text-primary); + border-bottom-left-radius: 4px; + width: 100%; +} + +.hub-msg-content pre { + background-color: var(--hub-bg-canvas); + border: 1px solid var(--hub-border-subtle); + border-radius: var(--hub-radius-sm); + padding: 0.75rem; + margin: 0.5rem 0; + overflow-x: auto; + font-family: var(--hub-font-mono); + font-size: 0.8rem; +} + +.hub-msg-content code { + font-family: var(--hub-font-mono); + background-color: rgba(255,255,255,0.06); + padding: 2px 4px; + border-radius: 4px; + font-size: 0.825rem; +} + +.hub-msg-content p { + margin-bottom: 0.5rem; +} + +.hub-msg-content p:last-child { + margin-bottom: 0; +} + +.hub-msg-reasoning { + background-color: rgba(255, 255, 255, 0.03); + border-left: 2px solid var(--hub-accent-current); + padding: 0.5rem 0.75rem; + margin-bottom: 0.6rem; + border-radius: 0 var(--hub-radius-sm) var(--hub-radius-sm) 0; + font-size: 0.8rem; + color: var(--hub-text-secondary); +} + +.hub-msg-reasoning summary { + cursor: pointer; + font-weight: 500; + color: var(--hub-text-muted); +} + +.hub-streaming-cursor { + display: inline-block; + width: 6px; + height: 14px; + background-color: var(--hub-accent-current); + margin-left: 4px; + vertical-align: middle; + animation: hubBlink 0.8s infinite; +} + +@keyframes hubBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Chat Input Bar */ +.hub-chat-input-wrapper { + padding: 0.75rem 1.25rem 1rem; + background-color: var(--hub-bg-sidebar); + border-top: 1px solid var(--hub-border-subtle); + flex-shrink: 0; +} + +.hub-chat-form { + max-width: 820px; + margin: 0 auto; + display: flex; + align-items: flex-end; + gap: 0.5rem; + background-color: var(--hub-bg-card); + border: 1px solid var(--hub-border-medium); + border-radius: var(--hub-radius-lg); + padding: 0.5rem 0.75rem; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.hub-chat-form:focus-within { + border-color: var(--hub-accent-current); + box-shadow: 0 0 0 2px var(--hub-accent-glow-current); +} + +.hub-chat-textarea { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--hub-text-primary); + font-size: 0.875rem; + font-family: inherit; + resize: none; + max-height: 160px; + min-height: 24px; + line-height: 1.5; + padding: 2px 0; +} + +.hub-btn-send { + background-color: var(--hub-accent-current); + border: none; + color: #ffffff; + width: 32px; + height: 32px; + border-radius: var(--hub-radius-md); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.hub-btn-send:hover { + opacity: 0.9; + transform: scale(1.04); +} + +.hub-btn-send:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.hub-btn-stop { + background-color: #ef4444; + border: none; + color: #ffffff; + width: 32px; + height: 32px; + border-radius: var(--hub-radius-md); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +.hub-stop-square { + width: 10px; + height: 10px; + background-color: #ffffff; + border-radius: 2px; +} + +.hub-chat-input-hint { + text-align: center; + font-size: 0.65rem; + color: var(--hub-text-muted); + margin-top: 0.4rem; +} + +/* Embedded Iframe Wrapper */ +.hub-iframe-wrapper { + width: 100%; + height: 100%; + position: relative; } .hub-workspace-iframe { width: 100%; height: 100%; border: none; - background-color: transparent; - display: block; + background-color: var(--hub-bg-canvas); } -/* Loading Overlay */ .hub-loader-overlay { position: absolute; inset: 0; @@ -391,8 +892,8 @@ html, body { flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - z-index: 5; + gap: 0.75rem; + z-index: 10; transition: opacity 0.2s ease; } @@ -402,53 +903,46 @@ html, body { } .hub-spinner { - width: 36px; - height: 36px; - border: 3px solid var(--hub-border-medium); + width: 24px; + height: 24px; + border: 2px solid var(--hub-border-medium); border-top-color: var(--hub-accent-current); - border-radius: 50%; - animation: spin 0.8s linear infinite; + border-radius: var(--hub-radius-full); + animation: hubSpin 0.6s linear infinite; } -@keyframes spin { +@keyframes hubSpin { to { transform: rotate(360deg); } } -/* Modal Overlay & Card */ +/* Modals */ .hub-modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.75); + background-color: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); - display: flex; + display: none; align-items: center; justify-content: center; - z-index: 50; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; + z-index: 100; } -.hub-modal-backdrop.open { - opacity: 1; - pointer-events: auto; +.hub-modal-backdrop.active { + display: flex; } .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; + width: 90%; + max-width: 520px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); overflow: hidden; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6); } .hub-modal-header { - padding: 1.25rem; + padding: 1rem 1.25rem; border-bottom: 1px solid var(--hub-border-subtle); display: flex; align-items: center; @@ -457,19 +951,20 @@ html, body { .hub-modal-body { padding: 1.25rem; - overflow-y: auto; display: flex; flex-direction: column; - gap: 1rem; + gap: 0.85rem; + max-height: 70vh; + overflow-y: auto; } .hub-modal-footer { - padding: 1rem 1.25rem; + padding: 0.75rem 1.25rem; border-top: 1px solid var(--hub-border-subtle); display: flex; - align-items: center; justify-content: flex-end; - gap: 0.75rem; + gap: 0.5rem; + background-color: var(--hub-bg-sidebar); } .hub-form-group { @@ -479,39 +974,34 @@ html, body { } .hub-form-label { - font-size: 0.8rem; - font-weight: 600; + font-size: 0.75rem; + font-weight: 500; 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: 1px solid var(--hub-border-subtle); border-radius: var(--hub-radius-sm); - padding: 0.6rem 0.75rem; + padding: 0.5rem 0.75rem; color: var(--hub-text-primary); - font-family: var(--hub-font-sans); - font-size: 0.875rem; + font-family: inherit; + font-size: 0.825rem; + outline: none; + transition: border-color 0.15s ease; } .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; + padding: 0.45rem 0.9rem; border-radius: var(--hub-radius-sm); - font-size: 0.85rem; - font-weight: 600; + font-size: 0.8rem; + font-weight: 500; cursor: pointer; - border: none; + border: 1px solid transparent; transition: all 0.15s ease; } @@ -525,78 +1015,12 @@ html, body { } .hub-btn-secondary { - background-color: var(--hub-bg-active); + background-color: var(--hub-bg-card-hover); color: var(--hub-text-secondary); + border-color: var(--hub-border-subtle); } .hub-btn-secondary:hover { - background-color: var(--hub-bg-card-hover); color: var(--hub-text-primary); -} - -/* Mode Toggle Selector (Claude Design) */ -.hub-mode-toggle { - display: none; - align-items: center; - border: 1px solid var(--hub-border-medium); - height: 36px; - border-radius: var(--hub-radius-md); - overflow: hidden; - background-color: var(--hub-bg-card); -} - -.hub-mode-toggle.visible { - display: flex; -} - -.hub-mode-tab { - display: flex; - align-items: center; - gap: 0.5rem; - height: 100%; - padding: 0 0.85rem; - border: none; - background: transparent; - color: var(--hub-text-secondary); - font-family: var(--hub-font-sans); - font-size: 0.8125rem; - font-weight: 700; - letter-spacing: 0.02em; - cursor: pointer; - transition: all 120ms ease; - user-select: none; -} - -.hub-mode-tab:hover { - background-color: var(--hub-bg-card-hover); - color: var(--hub-text-primary); -} - -.hub-mode-tab.active { - background: var(--hub-accent-current); - color: #ffffff; - cursor: default; - box-shadow: 0 0 10px var(--hub-accent-glow-current); -} - -.hub-mode-divider { - width: 1px; - height: 60%; - background-color: var(--hub-border-medium); - flex: none; -} - -@keyframes panelIn { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: none; - } -} - -.hub-canvas-anim { - animation: panelIn 140ms ease both; + border-color: var(--hub-border-medium); } diff --git a/static/css/tokens.css b/static/css/tokens.css index 0572495..09fbf4e 100644 --- a/static/css/tokens.css +++ b/static/css/tokens.css @@ -40,6 +40,9 @@ --accent-nabil: #8b5cf6; /* Nabil Purple / Gold */ --accent-nabil-glow: rgba(139, 92, 246, 0.25); + --accent-dsh: #06b6d4; /* DSH Cyan / DeepSeek */ + --accent-dsh-glow: rgba(6, 182, 212, 0.25); + /* Current Active Accent (Dynamically mapped via JS) */ --hub-accent-current: var(--accent-tt); --hub-accent-glow-current: var(--accent-tt-glow); @@ -55,4 +58,5 @@ --hub-sidebar-width: 260px; --hub-sidebar-collapsed-width: 72px; --hub-header-height: 56px; + --hub-conv-sidebar-width: 280px; } diff --git a/static/js/hub.js b/static/js/hub.js index 3e9644c..d940315 100644 --- a/static/js/hub.js +++ b/static/js/hub.js @@ -1,310 +1,753 @@ /** - * Hermes Hub — Client-side Workspace Switcher & Context Manager - * Routage natif par sous-domaine 1er niveau (tt-hub.yesminedor.tn, nyora-hub..., perso-hub..., nabil-hub..., dsh-hub...) - * Résout à 100% le chargement des assets absolus et la couverture TLS wildcard + * Hermes Hub — Client Application & Native Chat Engine */ -(function () { - let activeUniverse = "tt"; - let activeMode = "session"; // "session" ou "files" (pour l'univers nabil) +document.addEventListener('DOMContentLoaded', () => { + // Elements + const sidebar = document.getElementById('hub-sidebar'); + const btnToggleSidebar = document.getElementById('btn-toggle-sidebar'); + const universeBtns = document.querySelectorAll('.hub-universe-btn'); + const activeTitle = document.getElementById('active-universe-name'); + const activeScope = document.getElementById('active-universe-scope'); + const activeTagline = document.getElementById('active-universe-tagline'); + const nabilModeToggle = document.getElementById('nabil-mode-toggle'); + const tabSession = document.getElementById('tab-session'); + const tabFiles = document.getElementById('tab-files'); + + // Containers + const nativeChatLayout = document.getElementById('hub-native-chat'); + const iframeWrapper = document.getElementById('hub-iframe-wrapper'); + const iframe = document.getElementById('workspace-iframe'); + const loader = document.getElementById('hub-loader'); + + // Native Chat Elements + const btnNewChat = document.getElementById('btn-new-chat'); + const convSearch = document.getElementById('hub-conv-search'); + const convList = document.getElementById('hub-conv-list'); + const chatHeaderTitle = document.getElementById('current-chat-title'); + const chatHeaderStatus = document.getElementById('current-chat-status'); + const messagesContainer = document.getElementById('hub-messages-container'); + const chatForm = document.getElementById('hub-chat-form'); + const chatInput = document.getElementById('hub-chat-input'); + const btnSend = document.getElementById('btn-send-msg'); + const btnStop = document.getElementById('btn-stop-msg'); + const emptyStateName = document.getElementById('empty-state-name'); + + const btnPinActive = document.getElementById('btn-pin-active'); + const btnRenameActive = document.getElementById('btn-rename-active'); + const btnDeleteActive = document.getElementById('btn-delete-active'); + + // Modals + const personaModal = document.getElementById('persona-modal'); + const cloneModal = document.getElementById('clone-modal'); + const btnEditPersona = document.getElementById('btn-edit-persona'); + const btnCloneUniverse = document.getElementById('btn-clone-universe'); + const btnSavePersona = document.getElementById('btn-save-persona'); + const btnSubmitClone = document.getElementById('btn-submit-clone'); - 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"); - const nabilModeToggle = document.getElementById("nabil-mode-toggle"); - const tabSession = document.getElementById("tab-session"); - const tabFiles = document.getElementById("tab-files"); - const sidebar = document.getElementById("hub-sidebar"); - const toggleBtn = document.getElementById("btn-toggle-sidebar"); + // Application State + let currentUniverse = 'tt'; + let currentSessionKey = null; + let currentConversations = []; + let isStreaming = false; + let currentAbortController = null; + let currentMode = 'chat'; // 'chat' or 'files' - // 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); + // Sidebar Collapse Persistence + const isCollapsed = localStorage.getItem('hermes_hub_sidebar_collapsed') === 'true'; + if (isCollapsed && sidebar) { + sidebar.classList.add('collapsed'); } - function getTargetUrl(universeId, mode) { - const hostname = window.location.hostname; - - // 1. Production *-hub.yesminedor.tn (Routage par sous-domaine tiret) - if (hostname === "hub.yesminedor.tn" || hostname.endsWith(".yesminedor.tn")) { - if (universeId === "nabil" && mode === "files") { - return "https://dsh-hub.yesminedor.tn/"; - } - return `https://${universeId}-hub.yesminedor.tn/`; - } - - // 2. Test local *-hub.localhost ou *.localhost - if (hostname.endsWith(".localhost") || hostname === "localhost") { - const port = window.location.port ? `:${window.location.port}` : ""; - if (universeId === "nabil" && mode === "files") { - return `${window.location.protocol}//dsh-hub.localhost${port}/`; - } - return `${window.location.protocol}//${universeId}-hub.localhost${port}/`; - } - - // 3. Fallback IP direct / chemin relatif (tests curl/SSH) - if (universeId === "nabil" && mode === "files") { - return "/u/nabil/files/"; - } - return `/u/${universeId}/`; - } - - function setMode(mode) { - activeMode = mode; - if (tabSession && tabFiles) { - if (mode === "session") { - tabSession.classList.add("active"); - tabSession.setAttribute("aria-selected", "true"); - tabFiles.classList.remove("active"); - tabFiles.setAttribute("aria-selected", "false"); - } else { - tabFiles.classList.add("active"); - tabFiles.setAttribute("aria-selected", "true"); - tabSession.classList.remove("active"); - tabSession.setAttribute("aria-selected", "false"); - } - } - - // Recharger le canvas avec le mode choisi - loader.classList.remove("hidden"); - iframe.src = "about:blank"; - setTimeout(() => { - iframe.src = getTargetUrl(activeUniverse, activeMode); - }, 50); - } - - function switchUniverse(universeId) { - if (!universeId) return; - - const btn = document.querySelector(`.hub-universe-btn[data-universe="${universeId}"]`); - if (!btn) return; - - // 1. ISOLATION TOTALE DU STATE CLIENT - loader.classList.remove("hidden"); - iframe.src = "about:blank"; - - // 2. Mettre à jour l'univers actif - activeUniverse = universeId; - activeMode = "session"; - - // 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 || ""; - const supportsFiles = btn.dataset.supportsFiles === "true"; - - if (activeTitle) activeTitle.textContent = name; - if (activeScope) activeScope.textContent = `Scope: ${scope}`; - if (activeTagline) activeTagline.textContent = tagline; - - // 6. Afficher ou masquer le sélecteur toggle DSH (Claude Design) - if (nabilModeToggle) { - if (supportsFiles) { - nabilModeToggle.classList.add("visible"); - if (tabSession && tabFiles) { - tabSession.classList.add("active"); - tabSession.setAttribute("aria-selected", "true"); - tabFiles.classList.remove("active"); - tabFiles.setAttribute("aria-selected", "false"); - } - } else { - nabilModeToggle.classList.remove("visible"); - } - } - - // 7. Charger le nouvel univers via son sous-domaine dédié - setTimeout(() => { - iframe.src = getTargetUrl(activeUniverse, activeMode); - }, 50); - } - - // Sidebar Collapse / Expand Functionality - function setSidebarCollapsed(collapsed) { - if (!sidebar) return; - if (collapsed) { - sidebar.classList.add("collapsed"); - toggleBtn?.setAttribute("aria-label", "Déplier la barre latérale"); - toggleBtn?.setAttribute("title", "Déplier la barre latérale"); - localStorage.setItem("hermes_hub_sidebar_collapsed", "true"); - } else { - sidebar.classList.remove("collapsed"); - toggleBtn?.setAttribute("aria-label", "Replier la barre latérale"); - toggleBtn?.setAttribute("title", "Replier la barre latérale"); - localStorage.setItem("hermes_hub_sidebar_collapsed", "false"); - } - } - - // Écouter le chargement de l'iframe - if (iframe) { - iframe.addEventListener("load", () => { - if (iframe.src !== "about:blank") { - loader.classList.add("hidden"); - } + if (btnToggleSidebar && sidebar) { + btnToggleSidebar.addEventListener('click', (e) => { + e.stopPropagation(); + sidebar.classList.toggle('collapsed'); + localStorage.setItem('hermes_hub_sidebar_collapsed', sidebar.classList.contains('collapsed')); }); } - // 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(); + // Accent Mapping + const ACCENT_MAP = { + 'tt': { accent: 'var(--accent-tt)', glow: 'var(--accent-tt-glow)' }, + 'nyora': { accent: 'var(--accent-nyora)', glow: 'var(--accent-nyora-glow)' }, + 'perso': { accent: 'var(--accent-perso)', glow: 'var(--accent-perso-glow)' }, + 'nabil': { accent: 'var(--accent-nabil)', glow: 'var(--accent-nabil-glow)' }, + 'dsh': { accent: 'var(--accent-dsh)', glow: 'var(--accent-dsh-glow)' } + }; - data.forEach((u) => { - const dot = document.getElementById(`status-dot-${u.id}`); - if (dot) { - if (u.health && (u.health.status === "online" || u.health.status_code === 200 || u.health.status_code === 302)) { - 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})`; + function updateThemeAccent(universeId) { + const config = ACCENT_MAP[universeId] || ACCENT_MAP['tt']; + document.documentElement.style.setProperty('--hub-accent-current', config.accent); + document.documentElement.style.setProperty('--hub-accent-glow-current', config.glow); + } + + // Escape HTML helper + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; + } + + // Basic Markdown Parser (Code blocks, bold, italic, line breaks) + function renderMarkdown(text) { + if (!text) return ''; + let html = escapeHtml(text); + + // Code blocks with syntax box + html = html.replace(/```([a-zA-Z0-9_\-\.]*)\n([\s\S]*?)```/g, (match, lang, code) => { + return `
${code}
`; + }); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Bold + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Italic + html = html.replace(/\*([^*]+)\*/g, '$1'); + + // Line breaks + html = html.replace(/\n/g, '
'); + + return html; + } + + // Switch Universe + function switchUniverse(universeId) { + currentUniverse = universeId; + currentSessionKey = null; + currentMode = 'chat'; + + // Update active button + universeBtns.forEach(btn => { + const isTarget = btn.getAttribute('data-universe') === universeId; + btn.classList.toggle('active', isTarget); + if (isTarget) { + activeTitle.textContent = btn.getAttribute('data-name'); + activeScope.textContent = `Scope: ${btn.getAttribute('data-scope')}`; + activeTagline.textContent = btn.getAttribute('data-tagline'); + if (emptyStateName) emptyStateName.textContent = btn.getAttribute('data-name'); + } + }); + + updateThemeAccent(universeId); + + // Show/hide Nabil Files Toggle + if (universeId === 'nabil') { + nabilModeToggle.style.display = 'inline-flex'; + tabSession.classList.add('active'); + tabFiles.classList.remove('active'); + } else { + nabilModeToggle.style.display = 'none'; + } + + // Determine layout: DSH uses embedded client, others use Native Chat + if (universeId === 'dsh') { + showIframeView(`https://dsh-hub.yesminedor.tn/`); + } else { + showNativeChatView(); + loadConversations(); + } + } + + function showNativeChatView() { + nativeChatLayout.style.display = 'flex'; + iframeWrapper.style.display = 'none'; + if (iframe) iframe.src = 'about:blank'; + } + + function showIframeView(url) { + nativeChatLayout.style.display = 'none'; + iframeWrapper.style.display = 'block'; + if (loader) loader.classList.remove('hidden'); + if (iframe) { + iframe.src = url; + iframe.onload = () => { + if (loader) loader.classList.add('hidden'); + }; + } + } + + // Load Conversations List + async function loadConversations() { + convList.innerHTML = '
Chargement...
'; + + try { + const res = await fetch(`/api/chat/${currentUniverse}/conversations`); + const data = await res.json(); + currentConversations = data.conversations || []; + renderConversationsList(); + + if (currentConversations.length > 0 && !currentSessionKey) { + selectConversation(currentConversations[0].session_key); + } else if (!currentSessionKey) { + startNewConversation(false); + } + } catch (err) { + convList.innerHTML = '
Erreur de chargement
'; + } + } + + function renderConversationsList() { + const filter = (convSearch ? convSearch.value.trim().toLowerCase() : ''); + const filtered = currentConversations.filter(c => !filter || (c.title && c.title.toLowerCase().includes(filter))); + + if (filtered.length === 0) { + convList.innerHTML = '
Aucune discussion
'; + return; + } + + const pinned = filtered.filter(c => c.pinned); + const recents = filtered.filter(c => !c.pinned); + + let html = ''; + + if (pinned.length > 0) { + html += '
📌 Épinglées
'; + pinned.forEach(c => { + html += renderConvItem(c); + }); + } + + if (recents.length > 0) { + if (pinned.length > 0) html += '
Discussions récentes
'; + recents.forEach(c => { + html += renderConvItem(c); + }); + } + + convList.innerHTML = html; + + // Attach click events + convList.querySelectorAll('.hub-conv-item').forEach(item => { + item.addEventListener('click', (e) => { + if (e.target.closest('.hub-conv-action-btn')) return; + const key = item.getAttribute('data-session-key'); + selectConversation(key); + }); + }); + + // Attach actions + convList.querySelectorAll('.btn-pin-conv').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const key = btn.getAttribute('data-session-key'); + const isPinned = btn.getAttribute('data-pinned') === '1'; + await togglePin(key, !isPinned); + }); + }); + + convList.querySelectorAll('.btn-rename-conv').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const key = btn.getAttribute('data-session-key'); + const currentT = btn.getAttribute('data-title'); + const newTitle = prompt('Nouveau titre de la discussion :', currentT); + if (newTitle && newTitle.trim() && newTitle.trim() !== currentT) { + await renameConv(key, newTitle.trim()); + } + }); + }); + + convList.querySelectorAll('.btn-delete-conv').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const key = btn.getAttribute('data-session-key'); + if (confirm('Supprimer définitivement cette discussion ?')) { + await deleteConv(key); + } + }); + }); + } + + function renderConvItem(c) { + const isActive = c.session_key === currentSessionKey; + const timeStr = c.updated_at ? new Date(c.updated_at).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }) : ''; + + return ` +
+
+ ${c.pinned ? '📌 ' : ''}${escapeHtml(c.title || 'Discussion')} +
+ + + +
+
+
+ ${timeStr} +
+
+ `; + } + + // Select Conversation + async function selectConversation(sessionKey) { + currentSessionKey = sessionKey; + renderConversationsList(); + + const conv = currentConversations.find(c => c.session_key === sessionKey); + if (conv) { + chatHeaderTitle.textContent = conv.title || 'Discussion'; + if (btnPinActive) btnPinActive.textContent = conv.pinned ? '📍' : '📌'; + } + + messagesContainer.innerHTML = '
Chargement des messages...
'; + + try { + const res = await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}/messages`); + const data = await res.json(); + renderMessages(data.messages || []); + } catch (err) { + messagesContainer.innerHTML = '
Erreur de chargement des messages
'; + } + } + + // Start New Conversation + async function startNewConversation(focus = true) { + try { + const res = await fetch(`/api/chat/${currentUniverse}/conversations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Nouvelle conversation' }) + }); + const data = await res.json(); + if (data.ok && data.conversation) { + currentSessionKey = data.conversation.session_key; + currentConversations.unshift(data.conversation); + renderConversationsList(); + chatHeaderTitle.textContent = 'Nouvelle conversation'; + renderMessages([]); + if (focus && chatInput) chatInput.focus(); + } + } catch (err) { + console.error('Failed to create new conversation:', err); + } + } + + // Render Messages + function renderMessages(messages) { + if (!messages || messages.length === 0) { + messagesContainer.innerHTML = ` +
+
💬
+

Discussion avec ${escapeHtml(activeTitle.textContent)}

+

Posez une question ou donnez une instruction pour démarrer la session.

+
+ `; + return; + } + + let html = ''; + messages.forEach(m => { + const isUser = m.role === 'user'; + const avatarText = isUser ? 'VOUS' : currentUniverse.toUpperCase().slice(0, 2); + + let reasoningHtml = ''; + if (m.reasoning) { + reasoningHtml = ` +
+ Raisonnement de l'agent +
${escapeHtml(m.reasoning)}
+
+ `; + } + + html += ` +
+
${avatarText}
+
+ ${reasoningHtml} +
${renderMarkdown(m.content)}
+
+
+ `; + }); + + messagesContainer.innerHTML = html; + scrollToBottom(); + } + + function scrollToBottom() { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // Send Message & Handle SSE Streaming + async function sendMessage() { + const text = chatInput.value.trim(); + if (!text || isStreaming) return; + + if (!currentSessionKey) { + await startNewConversation(false); + } + + // Append user message immediately + const userMsgHtml = ` +
+
VOUS
+
+
${renderMarkdown(text)}
+
+
+ `; + + // Remove empty state if present + const emptyState = messagesContainer.querySelector('.hub-empty-state'); + if (emptyState) emptyState.remove(); + + messagesContainer.insertAdjacentHTML('beforeend', userMsgHtml); + chatInput.value = ''; + chatInput.style.height = 'auto'; + scrollToBottom(); + + // Prepare assistant message bubble with streaming cursor + const assistantId = `msg-stream-${Date.now()}`; + const assistantMsgHtml = ` +
+
${currentUniverse.toUpperCase().slice(0, 2)}
+
+
+
+
+ `; + messagesContainer.insertAdjacentHTML('beforeend', assistantMsgHtml); + scrollToBottom(); + + const streamBubble = document.getElementById(assistantId); + const streamTextEl = streamBubble.querySelector('.stream-text'); + const cursorEl = streamBubble.querySelector('.hub-streaming-cursor'); + + // UI Streaming state + setStreamingState(true); + chatHeaderStatus.textContent = 'En train de répondre...'; + + currentAbortController = new AbortController(); + + try { + const response = await fetch(`/api/chat/${currentUniverse}/conversations/${currentSessionKey}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text }), + signal: currentAbortController.signal + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let accumulatedText = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep last partial line + + for (const line of lines) { + if (line.startsWith('data: ')) { + const rawData = line.slice(6).trim(); + if (!rawData) continue; + try { + const data = JSON.parse(rawData); + + if (data.fullReplace && data.text !== undefined) { + // Workspace chunk with fullReplace: true + accumulatedText = data.text; + streamTextEl.innerHTML = renderMarkdown(accumulatedText); + } else if (data.text) { + accumulatedText = data.text; + streamTextEl.innerHTML = renderMarkdown(accumulatedText); + } else if (data.chunk) { + accumulatedText += data.chunk; + streamTextEl.innerHTML = renderMarkdown(accumulatedText); + } + scrollToBottom(); + } catch (e) {} } } - }); - } catch (e) { - console.warn("Health check error:", e); + } + + // Final rendering + if (cursorEl) cursorEl.remove(); + streamTextEl.innerHTML = renderMarkdown(accumulatedText || 'Message reçu.'); + chatHeaderStatus.textContent = 'Prêt'; + + // Refresh conversations list to update title / timestamp + loadConversations(); + + } catch (err) { + if (err.name === 'AbortError') { + if (cursorEl) cursorEl.remove(); + streamTextEl.innerHTML += '
[Réponse interrompue]'; + } else { + if (cursorEl) cursorEl.remove(); + streamTextEl.innerHTML = `Erreur lors de la réponse (${err.message}). Vérifiez l'instance.`; + } + chatHeaderStatus.textContent = 'Prêt'; + } finally { + setStreamingState(false); + currentAbortController = null; + scrollToBottom(); } } - // Modales Personnalité & Clonage - const personaModal = document.getElementById("persona-modal"); - const cloneModal = document.getElementById("clone-modal"); + function setStreamingState(streaming) { + isStreaming = streaming; + btnSend.style.display = streaming ? 'none' : 'flex'; + btnStop.style.display = streaming ? 'flex' : 'none'; + } - async function openPersonaModal() { + function stopStreaming() { + if (currentAbortController) { + currentAbortController.abort(); + } + } + + // Conversation Actions (Pin, Rename, Delete) + async function togglePin(sessionKey, pinned) { 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); + await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pinned: pinned }) + }); + loadConversations(); + } catch (err) { + console.error('Failed to toggle pin:', err); } } - 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 - }; - + async function renameConv(sessionKey, title) { try { - const res = await fetch(`/api/universes/${activeUniverse}/persona`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: title }) }); - if (!res.ok) throw new Error("Erreur de sauvegarde"); - personaModal.classList.remove("open"); - } catch (e) { - alert("Erreur lors de la sauvegarde : " + e.message); + if (sessionKey === currentSessionKey) { + chatHeaderTitle.textContent = title; + } + loadConversations(); + } catch (err) { + console.error('Failed to rename conversation:', err); } } - 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."); - + async function deleteConv(sessionKey) { try { - const res = await fetch(`/api/universes/${activeUniverse}/clone`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clone_name: cloneName }) + await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, { + method: 'DELETE' }); - 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); + if (sessionKey === currentSessionKey) { + currentSessionKey = null; + } + loadConversations(); + } catch (err) { + console.error('Failed to delete conversation:', err); } } - // Setup Event Listeners - document.addEventListener("DOMContentLoaded", () => { - document.querySelectorAll(".hub-universe-btn").forEach((btn) => { - btn.addEventListener("click", () => { - const uId = btn.dataset.universe; - switchUniverse(uId); - }); + // Event Listeners + if (btnNewChat) { + btnNewChat.addEventListener('click', () => startNewConversation(true)); + } + + if (convSearch) { + convSearch.addEventListener('input', () => renderConversationsList()); + } + + if (chatForm) { + chatForm.addEventListener('submit', (e) => { + e.preventDefault(); + sendMessage(); + }); + } + + if (chatInput) { + // Auto-resize textarea + chatInput.addEventListener('input', () => { + chatInput.style.height = 'auto'; + chatInput.style.height = Math.min(chatInput.scrollHeight, 160) + 'px'; }); - tabSession?.addEventListener("click", () => setMode("session")); - tabFiles?.addEventListener("click", () => setMode("files")); - - // Toggle Sidebar - toggleBtn?.addEventListener("click", () => { - const isCollapsed = sidebar?.classList.contains("collapsed"); - setSidebarCollapsed(!isCollapsed); + // Enter to submit (Shift+Enter for newline) + chatInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } }); + } - // Restore saved sidebar collapsed state - if (localStorage.getItem("hermes_hub_sidebar_collapsed") === "true") { - setSidebarCollapsed(true); - } + if (btnStop) { + btnStop.addEventListener('click', () => stopStreaming()); + } - 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); - - document.querySelectorAll(".hub-modal-close").forEach((btn) => { - btn.addEventListener("click", () => { - personaModal.classList.remove("open"); - cloneModal.classList.remove("open"); - }); + if (btnPinActive) { + btnPinActive.addEventListener('click', () => { + if (!currentSessionKey) return; + const conv = currentConversations.find(c => c.session_key === currentSessionKey); + if (conv) togglePin(currentSessionKey, !conv.pinned); }); + } - switchUniverse("tt"); - refreshHealthStatus(); - setInterval(refreshHealthStatus, 15000); + if (btnRenameActive) { + btnRenameActive.addEventListener('click', () => { + if (!currentSessionKey) return; + const conv = currentConversations.find(c => c.session_key === currentSessionKey); + const currentT = conv ? conv.title : 'Discussion'; + const newTitle = prompt('Nouveau titre de la discussion :', currentT); + if (newTitle && newTitle.trim()) renameConv(currentSessionKey, newTitle.trim()); + }); + } + + if (btnDeleteActive) { + btnDeleteActive.addEventListener('click', () => { + if (!currentSessionKey) return; + if (confirm('Supprimer définitivement cette discussion ?')) { + deleteConv(currentSessionKey); + } + }); + } + + // Nabil Mode Switcher (Chat Natif vs Fichiers DSH) + if (tabSession) { + tabSession.addEventListener('click', () => { + tabSession.classList.add('active'); + tabFiles.classList.remove('active'); + showNativeChatView(); + }); + } + + if (tabFiles) { + tabFiles.addEventListener('click', () => { + tabFiles.classList.add('active'); + tabSession.classList.remove('active'); + showIframeView(`https://dsh-hub.yesminedor.tn/`); + }); + } + + // Universe Switcher Buttons + universeBtns.forEach(btn => { + btn.addEventListener('click', () => { + const uId = btn.getAttribute('data-universe'); + switchUniverse(uId); + }); }); -})(); + + // Persona Modal Handlers + if (btnEditPersona && personaModal) { + btnEditPersona.addEventListener('click', async () => { + try { + const res = await fetch(`/api/personas/${currentUniverse}`); + const data = await res.json(); + document.getElementById('persona-name').value = data.name || ''; + document.getElementById('persona-tagline').value = data.tagline || ''; + document.getElementById('persona-tone').value = data.tone || ''; + document.getElementById('persona-style').value = data.style || ''; + document.getElementById('persona-principles').value = (data.principles || []).join('\n'); + document.getElementById('persona-prompt').value = data.system_prompt || ''; + personaModal.classList.add('active'); + } catch (err) { + alert('Impossible de charger le profil de cet univers.'); + } + }); + } + + if (btnSavePersona && personaModal) { + btnSavePersona.addEventListener('click', async () => { + const payload = { + name: document.getElementById('persona-name').value.trim(), + tagline: document.getElementById('persona-tagline').value.trim(), + tone: document.getElementById('persona-tone').value.trim(), + style: document.getElementById('persona-style').value.trim(), + principles: document.getElementById('persona-principles').value.split('\n').map(l => l.trim()).filter(Boolean), + system_prompt: document.getElementById('persona-prompt').value.trim() + }; + + try { + const res = await fetch(`/api/personas/${currentUniverse}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (res.ok) { + personaModal.classList.remove('active'); + alert('Personnalité mise à jour avec succès.'); + } + } catch (err) { + alert('Erreur lors de la sauvegarde.'); + } + }); + } + + // Clone Modal Handlers + if (btnCloneUniverse && cloneModal) { + btnCloneUniverse.addEventListener('click', () => { + document.getElementById('clone-source-name').textContent = activeTitle.textContent; + document.getElementById('clone-name').value = ''; + cloneModal.classList.add('active'); + }); + } + + if (btnSubmitClone && cloneModal) { + btnSubmitClone.addEventListener('click', async () => { + const cloneName = document.getElementById('clone-name').value.trim(); + if (!cloneName) return; + + try { + const res = await fetch(`/api/clones`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_universe_id: currentUniverse, + clone_name: cloneName + }) + }); + if (res.ok) { + cloneModal.classList.remove('active'); + alert('Clone créé avec succès.'); + } + } catch (err) { + alert('Erreur lors du clonage.'); + } + }); + } + + // Close modals + document.querySelectorAll('.hub-modal-close').forEach(btn => { + btn.addEventListener('click', () => { + if (personaModal) personaModal.classList.remove('active'); + if (cloneModal) cloneModal.classList.remove('active'); + }); + }); + + // Universe Health Monitoring Probe + async function probeUniverseStatuses() { + 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) { + dot.className = `hub-status-dot ${u.status}`; + dot.title = `Statut: ${u.status} (${u.status_code || 'N/A'})`; + } + }); + } catch (e) {} + } + + // Initialize + probeUniverseStatuses(); + setInterval(probeUniverseStatuses, 15000); + switchUniverse('tt'); +});