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>
|
||||
|
||||
@@ -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
@@ -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/`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user