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:
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
View File
@@ -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
}
)
+4 -4
View File
@@ -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>
+18 -8
View File
@@ -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>
+45
View File
@@ -420,11 +420,56 @@ body, html {
flex-direction: column;
flex-shrink: 0;
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 {
padding: 0.75rem;
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 {
+36 -13
View File
@@ -6,11 +6,14 @@ document.addEventListener('DOMContentLoaded', () => {
// Elements
const sidebar = document.getElementById('hub-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 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 dshModeToggle = document.getElementById('dsh-mode-toggle');
const tabSession = document.getElementById('tab-session');
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
const ACCENT_MAP = {
'tt': { accent: 'var(--accent-tt)', glow: 'var(--accent-tt-glow)' },
@@ -134,19 +162,14 @@ document.addEventListener('DOMContentLoaded', () => {
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
// Show/hide DSH Mode Toggle (Exclusif à l'univers 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/`);
} else {
if (dshModeToggle) dshModeToggle.style.display = 'none';
showNativeChatView();
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) {
tabSession.addEventListener('click', () => {
tabSession.classList.add('active');
tabFiles.classList.remove('active');
showNativeChatView();
showIframeView(`https://dsh-hub.yesminedor.tn/`);
});
}