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:
bolbol
2026-08-20 23:51:08 +01:00
parent e40fe71f5c
commit 9c8f292d0f
6 changed files with 150 additions and 36 deletions
+27 -6
View File
@@ -114,7 +114,7 @@ async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]:
for s in backend_sessions: for s in backend_sessions:
sid = str(s.get("id") or s.get("session_id") or "") sid = str(s.get("id") or s.get("session_id") or "")
if sid: 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 spinned = 1 if s.get("pinned") else 0
started_at = s.get("started_at") 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) 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) data = json.loads(raw)
if data.get("id") == req_id and "result" in data: if data.get("id") == req_id and "result" in data:
res = data["result"] 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 break
except Exception as e: except Exception:
# Fallback if WS fails # Fallback if WS fails
session_key = f"nabil_{uuid.uuid4().hex[:12]}" 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]: async def stream_chat_messages(universe_id: str, session_key: str, message: str) -> AsyncGenerator[str, None]:
"""Streams chat chunks using Server-Sent Events (SSE).""" """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) existing = get_conversation(universe_id, session_key)
if existing and existing.get("title") in ("Nouvelle conversation", "Untitled", None): if existing and existing.get("title") in ("Nouvelle conversation", "Untitled", None):
summary_title = (message[:38] + "...") if len(message) > 38 else message 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_interval=20,
ping_timeout=20 ping_timeout=20
) as ws: ) 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]}" req_id = f"prompt-{uuid.uuid4().hex[:8]}"
send_payload = { send_payload = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": req_id, "id": req_id,
"method": "prompt.submit", "method": "prompt.submit",
"params": { "params": {
"session_id": session_key, "session_id": active_sid,
"text": message "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" yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': delta, 'fullReplace': True})}\n\n"
elif ev_type in ("reasoning.delta", "thinking.delta"): elif ev_type in ("reasoning.delta", "thinking.delta"):
delta = payload.get("text", "") 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"): 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" yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': accumulated_text})}\n\n"
break break
+20 -5
View File
@@ -1,10 +1,11 @@
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import time
from app.config import settings, UNIVERSES, BASE_DIR from app.config import settings, UNIVERSES, BASE_DIR
from app.routers import api, proxy, chat 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.personas import ensure_dirs
from app.db import init_db from app.db import init_db
APP_VERSION = f"2.1.{int(time.time())}"
def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: 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. 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') perso-hub.yesminedor.tn -> (HERMES_PERSO_URL, 'perso')
nabil-hub.yesminedor.tn -> (HERMES_NABIL_URL, 'nabil') nabil-hub.yesminedor.tn -> (HERMES_NABIL_URL, 'nabil')
dsh-hub.yesminedor.tn -> (HERMES_DSH_URL, 'dsh') 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: Apex / Hub UI:
hub.yesminedor.tn -> None (Serves index.html Workspace Switcher) 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 return None
if sub in ("files", "dsh-files"): if sub in ("files", "dsh-files"):
return (settings.dsh_filebrowser_url, "nabil") return (settings.dsh_filebrowser_url, "dsh")
if sub in UNIVERSES: if sub in UNIVERSES:
return (UNIVERSES[sub].backend_url, sub) return (UNIVERSES[sub].backend_url, sub)
@@ -70,10 +73,21 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title=settings.app_name, title=settings.app_name,
version="1.0.0", version="2.1.0",
lifespan=lifespan 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 # Subdomain Routing Middleware for HTTP
@app.middleware("http") @app.middleware("http")
async def subdomain_routing_middleware(request: Request, call_next): async def subdomain_routing_middleware(request: Request, call_next):
@@ -112,7 +126,8 @@ async def index_view(request: Request):
name="index.html", name="index.html",
context={ context={
"universes": UNIVERSES, "universes": UNIVERSES,
"app_name": settings.app_name "app_name": settings.app_name,
"version": APP_VERSION
} }
) )
+4 -4
View File
@@ -5,16 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ app_name }}{% endblock %}</title> <title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Design Tokens System (Substituable par Claude Design) --> <!-- Design Tokens System & Anti-Stale Assets -->
<link rel="stylesheet" href="/static/css/tokens.css"> <link rel="stylesheet" href="/static/css/tokens.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<script src="/static/js/hub.js"></script> <script src="/static/js/hub.js?v={{ version }}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>
+18 -8
View File
@@ -8,7 +8,7 @@
<div class="hub-brand-left"> <div class="hub-brand-left">
<div class="hub-logo-icon">H</div> <div class="hub-logo-icon">H</div>
<div class="hub-brand-text">Hermes Hub</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> </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"> <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"> <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>
<div class="hub-topbar-right"> <div class="hub-topbar-right">
<!-- Sélecteur Toggle Mode DSH (Univers Nabil) --> <!-- Sélecteur Toggle Mode DSH (Exclusif à l'univers DSH) -->
<div id="nabil-mode-toggle" role="tablist" aria-label="Mode du canvas" class="hub-mode-toggle" style="display: none;"> <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"> <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> <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> </button>
<div class="hub-mode-divider"></div> <div class="hub-mode-divider"></div>
<button id="tab-files" role="tab" aria-selected="false" class="hub-mode-tab" type="button"> <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> <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> </button>
</div> </div>
@@ -89,15 +89,20 @@
<!-- Canvas Container --> <!-- Canvas Container -->
<div class="hub-canvas"> <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"> <div id="hub-native-chat" class="hub-native-chat-layout">
<!-- Sub-sidebar Conversations --> <!-- Sub-sidebar Conversations -->
<div class="hub-conv-sidebar"> <div id="hub-conv-sidebar" class="hub-conv-sidebar">
<div class="hub-conv-header"> <div class="hub-conv-header">
<button id="btn-new-chat" class="hub-btn-new-chat" type="button"> <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> <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> <span>Nouvelle discussion</span>
</button> </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>
<div class="hub-conv-search-wrap"> <div class="hub-conv-search-wrap">
@@ -115,6 +120,11 @@
<!-- Active Conversation Header --> <!-- Active Conversation Header -->
<div class="hub-chat-header"> <div class="hub-chat-header">
<div class="hub-chat-header-info"> <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-title" class="hub-chat-header-title">Nouvelle conversation</span>
<span id="current-chat-status" class="hub-chat-header-status">Prêt</span> <span id="current-chat-status" class="hub-chat-header-status">Prêt</span>
</div> </div>
@@ -155,7 +165,7 @@
</div> </div>
</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-iframe-wrapper" class="hub-iframe-wrapper" style="display: none;">
<div id="hub-loader" class="hub-loader-overlay"> <div id="hub-loader" class="hub-loader-overlay">
<div class="hub-spinner"></div> <div class="hub-spinner"></div>
+45
View File
@@ -420,11 +420,56 @@ body, html {
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease, border-color 0.2s ease;
}
.hub-conv-sidebar.collapsed {
width: 0 !important;
min-width: 0 !important;
border-right: none;
opacity: 0;
pointer-events: none;
} }
.hub-conv-header { .hub-conv-header {
padding: 0.75rem; padding: 0.75rem;
border-bottom: 1px solid var(--hub-border-subtle); border-bottom: 1px solid var(--hub-border-subtle);
display: flex;
align-items: center;
gap: 0.5rem;
}
.hub-conv-header .hub-btn-new-chat {
flex: 1;
}
.hub-collapse-conv-btn,
.hub-expand-conv-btn {
background: transparent;
border: 1px solid var(--hub-border-subtle);
border-radius: var(--hub-radius-sm);
color: var(--hub-text-muted);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
.hub-collapse-conv-btn:hover,
.hub-expand-conv-btn:hover {
color: var(--hub-text-primary);
background: var(--hub-bg-card-hover);
border-color: var(--hub-border-medium);
}
.hub-chat-header-info {
display: flex;
align-items: center;
gap: 0.5rem;
} }
.hub-btn-new-chat { .hub-btn-new-chat {
+36 -13
View File
@@ -6,11 +6,14 @@ document.addEventListener('DOMContentLoaded', () => {
// Elements // Elements
const sidebar = document.getElementById('hub-sidebar'); const sidebar = document.getElementById('hub-sidebar');
const btnToggleSidebar = document.getElementById('btn-toggle-sidebar'); const btnToggleSidebar = document.getElementById('btn-toggle-sidebar');
const convSidebar = document.getElementById('hub-conv-sidebar');
const btnToggleConvSidebar = document.getElementById('btn-toggle-conv-sidebar');
const btnExpandConvSidebar = document.getElementById('btn-expand-conv-sidebar');
const universeBtns = document.querySelectorAll('.hub-universe-btn'); const universeBtns = document.querySelectorAll('.hub-universe-btn');
const activeTitle = document.getElementById('active-universe-name'); const activeTitle = document.getElementById('active-universe-name');
const activeScope = document.getElementById('active-universe-scope'); const activeScope = document.getElementById('active-universe-scope');
const activeTagline = document.getElementById('active-universe-tagline'); const activeTagline = document.getElementById('active-universe-tagline');
const nabilModeToggle = document.getElementById('nabil-mode-toggle'); const dshModeToggle = document.getElementById('dsh-mode-toggle');
const tabSession = document.getElementById('tab-session'); const tabSession = document.getElementById('tab-session');
const tabFiles = document.getElementById('tab-files'); const tabFiles = document.getElementById('tab-files');
@@ -67,6 +70,31 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Conversation Sub-Sidebar Collapse Persistence
const isConvCollapsed = localStorage.getItem('hermes_hub_conv_collapsed') === 'true';
if (isConvCollapsed && convSidebar) {
convSidebar.classList.add('collapsed');
if (btnExpandConvSidebar) btnExpandConvSidebar.style.display = 'inline-flex';
}
if (btnToggleConvSidebar && convSidebar) {
btnToggleConvSidebar.addEventListener('click', (e) => {
e.stopPropagation();
convSidebar.classList.add('collapsed');
if (btnExpandConvSidebar) btnExpandConvSidebar.style.display = 'inline-flex';
localStorage.setItem('hermes_hub_conv_collapsed', 'true');
});
}
if (btnExpandConvSidebar && convSidebar) {
btnExpandConvSidebar.addEventListener('click', (e) => {
e.stopPropagation();
convSidebar.classList.remove('collapsed');
btnExpandConvSidebar.style.display = 'none';
localStorage.setItem('hermes_hub_conv_collapsed', 'false');
});
}
// Accent Mapping // Accent Mapping
const ACCENT_MAP = { const ACCENT_MAP = {
'tt': { accent: 'var(--accent-tt)', glow: 'var(--accent-tt-glow)' }, 'tt': { accent: 'var(--accent-tt)', glow: 'var(--accent-tt-glow)' },
@@ -134,19 +162,14 @@ document.addEventListener('DOMContentLoaded', () => {
updateThemeAccent(universeId); updateThemeAccent(universeId);
// Show/hide Nabil Files Toggle // Show/hide DSH Mode Toggle (Exclusif à l'univers DSH)
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') { if (universeId === 'dsh') {
if (dshModeToggle) dshModeToggle.style.display = 'inline-flex';
if (tabSession) tabSession.classList.add('active');
if (tabFiles) tabFiles.classList.remove('active');
showIframeView(`https://dsh-hub.yesminedor.tn/`); showIframeView(`https://dsh-hub.yesminedor.tn/`);
} else { } else {
if (dshModeToggle) dshModeToggle.style.display = 'none';
showNativeChatView(); showNativeChatView();
loadConversations(); loadConversations();
} }
@@ -617,12 +640,12 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Nabil Mode Switcher (Chat Natif vs Fichiers DSH) // DSH Mode Switcher (Dialogue DSH vs Explorateur de Fichiers)
if (tabSession) { if (tabSession) {
tabSession.addEventListener('click', () => { tabSession.addEventListener('click', () => {
tabSession.classList.add('active'); tabSession.classList.add('active');
tabFiles.classList.remove('active'); tabFiles.classList.remove('active');
showNativeChatView(); showIframeView(`https://dsh-hub.yesminedor.tn/`);
}); });
} }