From 9c8f292d0f5c3be3008b1dac419a1c4539b56ce6 Mon Sep 17 00:00:00 2001 From: bolbol Date: Thu, 20 Aug 2026 23:51:08 +0100 Subject: [PATCH] feat(phase4): permanent cache-control & asset versioning, canonical nabil stored_session_id & resume, dsh session persistence fix, dsh dialogue/files toggle, and collapsible conv sidebar --- app/chat_service.py | 33 ++++++++++++++++++++++----- app/main.py | 25 ++++++++++++++++---- app/templates/base.html | 8 +++---- app/templates/index.html | 26 ++++++++++++++------- static/css/style.css | 45 ++++++++++++++++++++++++++++++++++++ static/js/hub.js | 49 +++++++++++++++++++++++++++++----------- 6 files changed, 150 insertions(+), 36 deletions(-) diff --git a/app/chat_service.py b/app/chat_service.py index 40c725f..48fecfb 100644 --- a/app/chat_service.py +++ b/app/chat_service.py @@ -114,7 +114,7 @@ async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]: 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]}" + stitle = s.get("title") or s.get("display_name") or f"Session {sid[:15]}" 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) @@ -171,9 +171,10 @@ async def create_universe_conversation(universe_id: str, title: Optional[str] = data = json.loads(raw) if data.get("id") == req_id and "result" in data: res = data["result"] - session_key = res.get("session_id") or res.get("stored_session_id") + # Prioritize durable stored_session_id (format YYYYMMDD_HHMMSS_xxxxxx) over ephemeral handle + session_key = res.get("stored_session_id") or res.get("session_id") break - except Exception as e: + except Exception: # Fallback if WS fails session_key = f"nabil_{uuid.uuid4().hex[:12]}" @@ -234,7 +235,6 @@ async def get_conversation_messages(universe_id: str, session_key: str) -> List[ 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 @@ -272,13 +272,35 @@ async def stream_chat_messages(universe_id: str, session_key: str, message: str) ping_interval=20, ping_timeout=20 ) as ws: + # 1. First resume/activate the session to get the active live handle + resume_req_id = f"res-{uuid.uuid4().hex[:8]}" + await ws.send(json.dumps({ + "jsonrpc": "2.0", + "id": resume_req_id, + "method": "session.resume", + "params": {"session_id": session_key} + })) + + active_sid = session_key + try: + while True: + raw_res = await asyncio.wait_for(ws.recv(), timeout=4.0) + data_res = json.loads(raw_res) + if data_res.get("id") == resume_req_id: + if "result" in data_res: + active_sid = data_res["result"].get("session_id") or session_key + break + except Exception: + pass + + # 2. Submit prompt with active_sid req_id = f"prompt-{uuid.uuid4().hex[:8]}" send_payload = { "jsonrpc": "2.0", "id": req_id, "method": "prompt.submit", "params": { - "session_id": session_key, + "session_id": active_sid, "text": message } } @@ -300,7 +322,6 @@ async def stream_chat_messages(universe_id: str, session_key: str, message: str) yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': delta, 'fullReplace': True})}\n\n" elif ev_type in ("reasoning.delta", "thinking.delta"): delta = payload.get("text", "") - # We can stream reasoning as needed elif ev_type in ("message.finish", "session.idle", "reasoning.available") or data.get("method") in ("chat.done", "session.done"): yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': accumulated_text})}\n\n" break diff --git a/app/main.py b/app/main.py index 244b960..a7a833d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,11 @@ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pathlib import Path from typing import Optional, Tuple from contextlib import asynccontextmanager +import time from app.config import settings, UNIVERSES, BASE_DIR from app.routers import api, proxy, chat @@ -12,6 +13,8 @@ from app.proxy import close_http_client, proxy_request, proxy_websocket from app.personas import ensure_dirs from app.db import init_db +APP_VERSION = f"2.1.{int(time.time())}" + def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: """ Inspects Host header and returns (target_backend_url, universe_id) if it matches a universe hostname. @@ -22,7 +25,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: perso-hub.yesminedor.tn -> (HERMES_PERSO_URL, 'perso') nabil-hub.yesminedor.tn -> (HERMES_NABIL_URL, 'nabil') dsh-hub.yesminedor.tn -> (HERMES_DSH_URL, 'dsh') - files-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'nabil') + files-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'dsh') Apex / Hub UI: hub.yesminedor.tn -> None (Serves index.html Workspace Switcher) @@ -54,7 +57,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: return None if sub in ("files", "dsh-files"): - return (settings.dsh_filebrowser_url, "nabil") + return (settings.dsh_filebrowser_url, "dsh") if sub in UNIVERSES: return (UNIVERSES[sub].backend_url, sub) @@ -70,10 +73,21 @@ async def lifespan(app: FastAPI): app = FastAPI( title=settings.app_name, - version="1.0.0", + version="2.1.0", lifespan=lifespan ) +# Cache-Control & Anti-Stale Middleware for Hub UI & Statics +@app.middleware("http") +async def cache_control_middleware(request: Request, call_next): + response: Response = await call_next(request) + path = request.url.path + if path.startswith("/static/") or path == "/" or path.endswith(".html"): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + # Subdomain Routing Middleware for HTTP @app.middleware("http") async def subdomain_routing_middleware(request: Request, call_next): @@ -112,7 +126,8 @@ async def index_view(request: Request): name="index.html", context={ "universes": UNIVERSES, - "app_name": settings.app_name + "app_name": settings.app_name, + "version": APP_VERSION } ) diff --git a/app/templates/base.html b/app/templates/base.html index d817f54..3335f05 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,16 +5,16 @@ {% block title %}{{ app_name }}{% endblock %} - - - + + + {% block extra_head %}{% endblock %} {% block content %}{% endblock %} - + {% block extra_scripts %}{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 683bc70..cf893e7 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -8,7 +8,7 @@
H
Hermes Hub
-
v2.0
+
v2.1
@@ -89,15 +89,20 @@
- +
-
+
+
@@ -115,6 +120,11 @@
+ Nouvelle conversation Prêt
@@ -155,7 +165,7 @@
- +