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
This commit is contained in:
+27
-6
@@ -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
|
||||
|
||||
+20
-5
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
|
||||
<!-- Design Tokens System (Substituable par Claude Design) -->
|
||||
<link rel="stylesheet" href="/static/css/tokens.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<!-- Design Tokens System & Anti-Stale Assets -->
|
||||
<link rel="stylesheet" href="/static/css/tokens.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<script src="/static/js/hub.js"></script>
|
||||
<script src="/static/js/hub.js?v={{ version }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="hub-brand-left">
|
||||
<div class="hub-logo-icon">H</div>
|
||||
<div class="hub-brand-text">Hermes Hub</div>
|
||||
<div class="hub-brand-badge">v2.0</div>
|
||||
<div class="hub-brand-badge">v2.1</div>
|
||||
</div>
|
||||
<button id="btn-toggle-sidebar" class="hub-collapse-btn" type="button" aria-label="Replier la barre latérale" title="Replier la barre latérale">
|
||||
<svg class="hub-collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -70,16 +70,16 @@
|
||||
</div>
|
||||
|
||||
<div class="hub-topbar-right">
|
||||
<!-- Sélecteur Toggle Mode DSH (Univers Nabil) -->
|
||||
<div id="nabil-mode-toggle" role="tablist" aria-label="Mode du canvas" class="hub-mode-toggle" style="display: none;">
|
||||
<!-- Sélecteur Toggle Mode DSH (Exclusif à l'univers DSH) -->
|
||||
<div id="dsh-mode-toggle" role="tablist" aria-label="Mode DSH" class="hub-mode-toggle" style="display: none;">
|
||||
<button id="tab-session" role="tab" aria-selected="true" class="hub-mode-tab active" type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>Chat Natif</span>
|
||||
<span>💬 Dialogue</span>
|
||||
</button>
|
||||
<div class="hub-mode-divider"></div>
|
||||
<button id="tab-files" role="tab" aria-selected="false" class="hub-mode-tab" type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M4 20h14a2 2 0 0 0 2-2V9H12L10 6H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1z"></path></svg>
|
||||
<span>Fichiers DSH</span>
|
||||
<span>📁 Explorateur</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -89,15 +89,20 @@
|
||||
|
||||
<!-- Canvas Container -->
|
||||
<div class="hub-canvas">
|
||||
<!-- 1. Native Chat Two-Column Interface -->
|
||||
<!-- 1. Native Chat Two-Column Interface (TT, Nyora, Perso, Nabil) -->
|
||||
<div id="hub-native-chat" class="hub-native-chat-layout">
|
||||
<!-- Sub-sidebar Conversations -->
|
||||
<div class="hub-conv-sidebar">
|
||||
<div id="hub-conv-sidebar" class="hub-conv-sidebar">
|
||||
<div class="hub-conv-header">
|
||||
<button id="btn-new-chat" class="hub-btn-new-chat" type="button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>Nouvelle discussion</span>
|
||||
</button>
|
||||
<button id="btn-toggle-conv-sidebar" class="hub-collapse-conv-btn" type="button" aria-label="Masquer la liste des discussions" title="Masquer la liste des discussions">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hub-conv-search-wrap">
|
||||
@@ -115,6 +120,11 @@
|
||||
<!-- Active Conversation Header -->
|
||||
<div class="hub-chat-header">
|
||||
<div class="hub-chat-header-info">
|
||||
<button id="btn-expand-conv-sidebar" class="hub-expand-conv-btn" type="button" aria-label="Afficher les discussions" title="Afficher les discussions" style="display: none;">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="current-chat-title" class="hub-chat-header-title">Nouvelle conversation</span>
|
||||
<span id="current-chat-status" class="hub-chat-header-status">Prêt</span>
|
||||
</div>
|
||||
@@ -155,7 +165,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Embedded Iframe View (Used for DSH client and DSH Filebrowser) -->
|
||||
<!-- 2. Embedded Iframe View (Used exclusively for DSH Dialogue and DSH Files) -->
|
||||
<div id="hub-iframe-wrapper" class="hub-iframe-wrapper" style="display: none;">
|
||||
<div id="hub-loader" class="hub-loader-overlay">
|
||||
<div class="hub-spinner"></div>
|
||||
|
||||
Reference in New Issue
Block a user