feat(chat): implement native hub chat with sqlite conversations store, SSE streaming and DSH 5th universe integration

This commit is contained in:
bolbol
2026-08-20 23:08:06 +01:00
parent 9580aead5d
commit c040c173df
11 changed files with 2090 additions and 572 deletions
+334
View File
@@ -0,0 +1,334 @@
import json
import uuid
import time
import asyncio
import httpx
import websockets
from typing import Dict, Any, List, Optional, AsyncGenerator
from app.config import UNIVERSES, settings
from app.db import (
list_conversations,
get_conversation,
save_conversation,
update_conversation,
delete_conversation_record
)
# Auth token & cookie cache
_AUTH_COOKIES: Dict[str, Dict[str, Any]] = {}
async def get_workspace_cookie(universe_id: str) -> str:
"""Authenticates to hermes-workspace (TT/Nyora/Perso) and caches the session cookie."""
now = time.time()
cached = _AUTH_COOKIES.get(universe_id)
if cached and cached.get("expires_at", 0) > now + 60:
return cached["cookie"]
universe = UNIVERSES.get(universe_id)
if not universe:
raise ValueError(f"Unknown universe {universe_id}")
password = ""
if universe_id == "tt":
password = settings.hermes_tt_password
elif universe_id == "nyora":
password = settings.hermes_nyora_password
elif universe_id == "perso":
password = settings.hermes_perso_password
auth_url = f"{universe.backend_url}/api/auth"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(auth_url, json={"password": password})
if resp.status_code != 200:
raise RuntimeError(f"Failed to authenticate with {universe_id}: {resp.status_code} {resp.text}")
cookie_header = resp.headers.get("set-cookie", "")
# Extract claude-auth cookie
token_part = ""
for part in cookie_header.split(";"):
if "claude-auth=" in part:
token_part = part.strip()
break
if not token_part and "claude-auth=" in cookie_header:
token_part = cookie_header.split(";")[0].strip()
cookie_str = token_part or cookie_header.split(";")[0].strip()
_AUTH_COOKIES[universe_id] = {
"cookie": cookie_str,
"expires_at": now + (25 * 86400) # 25 days validity
}
return cookie_str
async def get_nabil_session_cookie() -> str:
"""Authenticates to hermes-nabil and caches JWT session cookies."""
now = time.time()
cached = _AUTH_COOKIES.get("nabil")
if cached and cached.get("expires_at", 0) > now + 60:
return cached["cookie"]
universe = UNIVERSES.get("nabil")
login_url = f"{universe.backend_url}/auth/password-login"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(login_url, json={
"provider": "basic",
"username": settings.hermes_nabil_username,
"password": settings.hermes_nabil_password
})
if resp.status_code != 200:
raise RuntimeError(f"Failed to authenticate with Nabil: {resp.status_code} {resp.text}")
cookies_list = resp.headers.get_list("set-cookie") if hasattr(resp.headers, "get_list") else [resp.headers.get("set-cookie", "")]
cookie_parts = []
for c in cookies_list:
if c:
cookie_parts.append(c.split(";")[0].strip())
cookie_str = "; ".join(cookie_parts)
_AUTH_COOKIES["nabil"] = {
"cookie": cookie_str,
"expires_at": now + 3600 # 1 hour
}
return cookie_str
async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]:
"""Lists conversations for a given universe."""
if universe_id == "nabil":
# Sync from Nabil backend and merge with local SQLite store
try:
cookie = await get_nabil_session_cookie()
async with httpx.AsyncClient(timeout=6.0) as client:
resp = await client.get("http://hermes-nabil:9119/api/sessions?limit=50", headers={"Cookie": cookie})
if resp.status_code == 200:
data = resp.json()
backend_sessions = data.get("sessions", [])
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]}"
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)
save_conversation("nabil", sid, title=stitle, pinned=spinned, created_at=c_at)
except Exception as e:
# Fallback to local store if backend unreachable
pass
return list_conversations("nabil")
elif universe_id in ("tt", "nyora", "perso"):
# For workspace backends, SQLite is the canonical index
return list_conversations(universe_id)
else:
return []
async def create_universe_conversation(universe_id: str, title: Optional[str] = None) -> Dict[str, Any]:
"""Creates a new conversation in the universe and indexes it in Hub SQLite."""
if universe_id in ("tt", "nyora", "perso"):
cookie = await get_workspace_cookie(universe_id)
universe = UNIVERSES[universe_id]
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.post(f"{universe.backend_url}/api/sessions", json={}, headers={"Cookie": cookie})
if resp.status_code in (200, 201):
data = resp.json()
session_key = data.get("sessionKey") or data.get("friendlyId") or str(uuid.uuid4())
else:
session_key = str(uuid.uuid4())
conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation")
return conv
elif universe_id == "nabil":
session_key = str(uuid.uuid4())
conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation")
return conv
else:
raise ValueError(f"Unknown universe {universe_id}")
async def get_conversation_messages(universe_id: str, session_key: str) -> List[Dict[str, Any]]:
"""Fetches full message history for a conversation."""
if universe_id in ("tt", "nyora", "perso"):
cookie = await get_workspace_cookie(universe_id)
universe = UNIVERSES[universe_id]
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
f"{universe.backend_url}/api/session-history?key={session_key}&limit=200",
headers={"Cookie": cookie}
)
if resp.status_code == 200:
data = resp.json()
raw_messages = data.get("messages", [])
normalized = []
for m in raw_messages:
normalized.append({
"id": str(m.get("id", "")),
"role": m.get("role", "assistant"),
"content": m.get("content", ""),
"timestamp": m.get("timestamp", int(time.time() * 1000))
})
return normalized
return []
elif universe_id == "nabil":
cookie = await get_nabil_session_cookie()
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
f"http://hermes-nabil:9119/api/sessions/{session_key}/messages?limit=200",
headers={"Cookie": cookie}
)
if resp.status_code == 200:
data = resp.json()
raw_messages = data.get("messages", [])
normalized = []
for m in raw_messages:
content = m.get("content", "")
# If assistant only has tool calls, present readable summary
if not content and m.get("tool_calls"):
tool_names = [tc.get("function", {}).get("name", "tool") for tc in m.get("tool_calls", [])]
content = f"[Outils exécutés : {', '.join(tool_names)}]"
normalized.append({
"id": str(m.get("id", "")),
"role": m.get("role", "assistant"),
"content": content,
"reasoning": m.get("reasoning") or m.get("reasoning_content"),
"timestamp": int((m.get("timestamp") or time.time()) * 1000)
})
return normalized
return []
return []
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
update_conversation(universe_id, session_key, title=summary_title)
if universe_id in ("tt", "nyora", "perso"):
cookie = await get_workspace_cookie(universe_id)
universe = UNIVERSES[universe_id]
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream(
"POST",
f"{universe.backend_url}/api/send-stream",
json={"sessionKey": session_key, "message": message},
headers={"Cookie": cookie, "Content-Type": "application/json"}
) as response:
async for line in response.aiter_lines():
if line:
yield f"{line}\n"
else:
yield "\n"
# Touch updated_at
save_conversation(universe_id, session_key)
elif universe_id == "nabil":
cookie = await get_nabil_session_cookie()
# Acquire ws ticket
async with httpx.AsyncClient(timeout=8.0) as client:
resp_ticket = await client.post("http://hermes-nabil:9119/api/auth/ws-ticket", json={}, headers={"Cookie": cookie})
ticket = resp_ticket.json().get("ticket") if resp_ticket.status_code == 200 else ""
yield "event: started\ndata: {\"status\": \"connecting\"}\n\n"
ws_url = f"ws://hermes-nabil:9119/api/ws?ticket={ticket}"
try:
async with websockets.connect(
ws_url,
additional_headers={"Cookie": cookie}
) as ws:
# Send chat message via JSON-RPC
req_id = str(uuid.uuid4())
send_payload = {
"jsonrpc": "2.0",
"id": req_id,
"method": "chat.send",
"params": {
"session_id": session_key,
"message": message
}
}
await ws.send(json.dumps(send_payload))
accumulated_text = ""
while True:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=45.0)
data = json.loads(raw)
method = data.get("method")
params = data.get("params", {})
if method == "chat.chunk":
chunk_text = params.get("text", "")
accumulated_text += chunk_text
yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': chunk_text, 'fullReplace': True})}\n\n"
elif method in ("chat.done", "session.done", "gateway.ready"):
if method != "gateway.ready":
yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': accumulated_text})}\n\n"
break
elif data.get("id") == req_id and "result" in data:
res_text = data["result"].get("content") or data["result"].get("text") or accumulated_text
yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': res_text})}\n\n"
break
except asyncio.TimeoutError:
break
except Exception as e:
yield f"event: chunk\ndata: {json.dumps({'text': f'Erreur de communication Nabil: {str(e)}', 'fullReplace': True})}\n\n"
yield f"event: done\ndata: {json.dumps({'state': 'error'})}\n\n"
save_conversation(universe_id, session_key)
async def rename_universe_conversation(universe_id: str, session_key: str, title: str) -> bool:
"""Renames a conversation in SQLite and on Nabil backend if applicable."""
update_conversation(universe_id, session_key, title=title)
if universe_id == "nabil":
try:
cookie = await get_nabil_session_cookie()
async with httpx.AsyncClient(timeout=5.0) as client:
await client.patch(
f"http://hermes-nabil:9119/api/sessions/{session_key}",
json={"title": title},
headers={"Cookie": cookie}
)
except Exception:
pass
return True
async def pin_universe_conversation(universe_id: str, session_key: str, pinned: bool) -> bool:
"""Pins/unpins a conversation in SQLite and on Nabil backend if applicable."""
update_conversation(universe_id, session_key, pinned=pinned)
if universe_id == "nabil":
try:
cookie = await get_nabil_session_cookie()
async with httpx.AsyncClient(timeout=5.0) as client:
await client.patch(
f"http://hermes-nabil:9119/api/sessions/{session_key}",
json={"pinned": pinned},
headers={"Cookie": cookie}
)
except Exception:
pass
return True
async def delete_universe_conversation(universe_id: str, session_key: str) -> bool:
"""Deletes a conversation from SQLite and from backend if supported."""
delete_conversation_record(universe_id, session_key)
if universe_id in ("tt", "nyora", "perso"):
try:
cookie = await get_workspace_cookie(universe_id)
universe = UNIVERSES[universe_id]
async with httpx.AsyncClient(timeout=5.0) as client:
await client.delete(
f"{universe.backend_url}/api/sessions?sessionKey={session_key}",
headers={"Cookie": cookie}
)
except Exception:
pass
elif universe_id == "nabil":
try:
cookie = await get_nabil_session_cookie()
async with httpx.AsyncClient(timeout=5.0) as client:
await client.delete(
f"http://hermes-nabil:9119/api/sessions/{session_key}",
headers={"Cookie": cookie}
)
except Exception:
pass
return True
+33 -6
View File
@@ -8,6 +8,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.getenv("HUB_DATA_DIR", BASE_DIR / "data")) DATA_DIR = Path(os.getenv("HUB_DATA_DIR", BASE_DIR / "data"))
PERSONAS_DIR = DATA_DIR / "personas" PERSONAS_DIR = DATA_DIR / "personas"
CLONES_DIR = DATA_DIR / "clones" CLONES_DIR = DATA_DIR / "clones"
DB_PATH = DATA_DIR / "hub.db"
class UniverseConfig(BaseModel): class UniverseConfig(BaseModel):
id: str id: str
@@ -22,6 +23,7 @@ class UniverseConfig(BaseModel):
enabled: bool = True enabled: bool = True
supports_files: bool = False supports_files: bool = False
files_url: Optional[str] = None files_url: Optional[str] = None
is_external_app: bool = False
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "Hermes Hub" app_name: str = "Hermes Hub"
@@ -38,8 +40,16 @@ class Settings(BaseSettings):
hermes_nyora_url: str = os.getenv("HERMES_NYORA_URL", "http://100.86.197.88:3020") hermes_nyora_url: str = os.getenv("HERMES_NYORA_URL", "http://100.86.197.88:3020")
hermes_perso_url: str = os.getenv("HERMES_PERSO_URL", "http://100.86.197.88:3031") hermes_perso_url: str = os.getenv("HERMES_PERSO_URL", "http://100.86.197.88:3031")
hermes_nabil_url: str = os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119") hermes_nabil_url: str = os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119")
hermes_dsh_url: str = os.getenv("HERMES_DSH_URL", "http://dsh-vps:3080")
dsh_filebrowser_url: str = os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080") dsh_filebrowser_url: str = os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080")
# Backend Passwords
hermes_tt_password: str = os.getenv("HERMES_TT_PASSWORD", "XiEdCtyWETbzpQ7dxrRyvAYu")
hermes_nyora_password: str = os.getenv("HERMES_NYORA_PASSWORD", "juoKPfPGuo39wKCn9WJ0kY0_")
hermes_perso_password: str = os.getenv("HERMES_PERSO_PASSWORD", "-6Q12oViKsgZgIL82Kspa_dV")
hermes_nabil_username: str = os.getenv("HERMES_NABIL_USERNAME", "nabil")
hermes_nabil_password: str = os.getenv("HERMES_NABIL_PASSWORD", "NabilMasterHermes2026!")
# Hub Secret — OBLIGATOIRE, aucun fallback codé en dur (Fail-Fast au démarrage) # Hub Secret — OBLIGATOIRE, aucun fallback codé en dur (Fail-Fast au démarrage)
hub_secret: str = Field(..., min_length=16, description="Clé secrète maîtresse requise pour Hermes Hub") hub_secret: str = Field(..., min_length=16, description="Clé secrète maîtresse requise pour Hermes Hub")
@@ -72,7 +82,8 @@ UNIVERSES: Dict[str, UniverseConfig] = {
accent_token="--accent-tt", accent_token="--accent-tt",
icon="briefcase", icon="briefcase",
persona_file="tt.yaml", persona_file="tt.yaml",
supports_files=False supports_files=False,
is_external_app=False
), ),
"nyora": UniverseConfig( "nyora": UniverseConfig(
id="nyora", id="nyora",
@@ -84,7 +95,8 @@ UNIVERSES: Dict[str, UniverseConfig] = {
accent_token="--accent-nyora", accent_token="--accent-nyora",
icon="sparkles", icon="sparkles",
persona_file="nyora.yaml", persona_file="nyora.yaml",
supports_files=False supports_files=False,
is_external_app=False
), ),
"perso": UniverseConfig( "perso": UniverseConfig(
id="perso", id="perso",
@@ -96,20 +108,35 @@ UNIVERSES: Dict[str, UniverseConfig] = {
accent_token="--accent-perso", accent_token="--accent-perso",
icon="home", icon="home",
persona_file="perso.yaml", persona_file="perso.yaml",
supports_files=False supports_files=False,
is_external_app=False
), ),
"nabil": UniverseConfig( "nabil": UniverseConfig(
id="nabil", id="nabil",
name="Nabil Master", name="Nabil Master",
tagline="Orchestration & DSH", tagline="Orchestration & Code",
description="Master Agent VPS, exécution de code & DeepSeek Harness", description="Master Agent VPS, exécution de code & supervision DSH",
backend_url=os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119"), backend_url=os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119"),
scope="nabil", scope="nabil",
accent_token="--accent-nabil", accent_token="--accent-nabil",
icon="terminal", icon="terminal",
persona_file="nabil.yaml", persona_file="nabil.yaml",
supports_files=True, supports_files=True,
files_url=os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080") files_url=os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080"),
is_external_app=False
),
"dsh": UniverseConfig(
id="dsh",
name="DeepSeek Harness",
tagline="DSH Autonomous Agent",
description="Plateforme DeepSeek Harness : agents autonomes, sous-agents, trajectoires et tâches",
backend_url=os.getenv("HERMES_DSH_URL", "http://dsh-vps:3080"),
scope="dsh",
accent_token="--accent-dsh",
icon="cpu",
persona_file="dsh.yaml",
supports_files=False,
is_external_app=True
) )
} }
+117
View File
@@ -0,0 +1,117 @@
import sqlite3
import time
from pathlib import Path
from typing import List, Dict, Any, Optional
from app.config import DB_PATH, DATA_DIR
def init_db():
DATA_DIR.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS hub_conversations (
universe_id TEXT NOT NULL,
session_key TEXT NOT NULL,
title TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (universe_id, session_key)
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_hub_conv_universe
ON hub_conversations(universe_id, pinned DESC, updated_at DESC)
""")
conn.commit()
def list_conversations(universe_id: str) -> List[Dict[str, Any]]:
init_db()
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT universe_id, session_key, title, created_at, updated_at, pinned
FROM hub_conversations
WHERE universe_id = ?
ORDER BY pinned DESC, updated_at DESC
""", (universe_id,))
rows = cursor.fetchall()
return [dict(r) for r in rows]
def get_conversation(universe_id: str, session_key: str) -> Optional[Dict[str, Any]]:
init_db()
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT universe_id, session_key, title, created_at, updated_at, pinned
FROM hub_conversations
WHERE universe_id = ? AND session_key = ?
""", (universe_id, session_key))
row = cursor.fetchone()
return dict(row) if row else None
def save_conversation(
universe_id: str,
session_key: str,
title: Optional[str] = None,
pinned: Optional[int] = None,
created_at: Optional[int] = None,
updated_at: Optional[int] = None
) -> Dict[str, Any]:
init_db()
now = int(time.time() * 1000)
c_at = created_at or now
u_at = updated_at or now
p_val = 1 if pinned else 0
t_val = title or "Nouvelle conversation"
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO hub_conversations (universe_id, session_key, title, created_at, updated_at, pinned)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(universe_id, session_key) DO UPDATE SET
title = COALESCE(excluded.title, hub_conversations.title),
pinned = COALESCE(excluded.pinned, hub_conversations.pinned),
updated_at = excluded.updated_at
""", (universe_id, session_key, t_val, c_at, u_at, p_val))
conn.commit()
return get_conversation(universe_id, session_key)
def update_conversation(
universe_id: str,
session_key: str,
title: Optional[str] = None,
pinned: Optional[bool] = None
) -> Optional[Dict[str, Any]]:
init_db()
existing = get_conversation(universe_id, session_key)
if not existing:
return None
now = int(time.time() * 1000)
new_title = title if title is not None else existing["title"]
new_pinned = (1 if pinned else 0) if pinned is not None else existing["pinned"]
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE hub_conversations
SET title = ?, pinned = ?, updated_at = ?
WHERE universe_id = ? AND session_key = ?
""", (new_title, new_pinned, now, universe_id, session_key))
conn.commit()
return get_conversation(universe_id, session_key)
def delete_conversation_record(universe_id: str, session_key: str) -> bool:
init_db()
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM hub_conversations
WHERE universe_id = ? AND session_key = ?
""", (universe_id, session_key))
conn.commit()
return cursor.rowcount > 0
+8 -4
View File
@@ -7,9 +7,10 @@ from typing import Optional, Tuple
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.config import settings, UNIVERSES, BASE_DIR from app.config import settings, UNIVERSES, BASE_DIR
from app.routers import api, proxy from app.routers import api, proxy, chat
from app.proxy import close_http_client, proxy_request, proxy_websocket 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
def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]:
""" """
@@ -20,7 +21,8 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]:
nyora-hub.yesminedor.tn -> (HERMES_NYORA_URL, 'nyora') nyora-hub.yesminedor.tn -> (HERMES_NYORA_URL, 'nyora')
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 -> (DSH_FILEBROWSER_URL, 'nabil') dsh-hub.yesminedor.tn -> (HERMES_DSH_URL, 'dsh')
files-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'nabil')
Apex / Hub UI: Apex / Hub UI:
hub.yesminedor.tn -> None (Serves index.html Workspace Switcher) hub.yesminedor.tn -> None (Serves index.html Workspace Switcher)
@@ -51,9 +53,9 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]:
if not sub or sub in ("hub", "www"): if not sub or sub in ("hub", "www"):
return None return None
if sub in ("dsh", "files", "dsh-files"): if sub in ("files", "dsh-files"):
return (settings.dsh_filebrowser_url, "nabil") return (settings.dsh_filebrowser_url, "nabil")
if sub in UNIVERSES: if sub in UNIVERSES:
return (UNIVERSES[sub].backend_url, sub) return (UNIVERSES[sub].backend_url, sub)
@@ -62,6 +64,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
ensure_dirs() ensure_dirs()
init_db()
yield yield
await close_http_client() await close_http_client()
@@ -99,6 +102,7 @@ templates = Jinja2Templates(directory=str(templates_dir))
# Include Routers # Include Routers
app.include_router(api.router) app.include_router(api.router)
app.include_router(proxy.router) app.include_router(proxy.router)
app.include_router(chat.router)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@app.head("/", response_class=HTMLResponse) @app.head("/", response_class=HTMLResponse)
+36 -30
View File
@@ -51,10 +51,6 @@ async def close_http_client():
_http_transport = None _http_transport = None
def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str: def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str:
"""
Rewrites the Path attribute of a Set-Cookie header to /u/{universe_id}/
when using path-based proxying.
"""
target_path = f"/u/{universe_id}/" target_path = f"/u/{universe_id}/"
if re.search(r'(?i)\bpath=[^;]*', cookie_header): if re.search(r'(?i)\bpath=[^;]*', cookie_header):
return re.sub(r'(?i)\bpath=[^;]*', f'Path={target_path}', cookie_header) return re.sub(r'(?i)\bpath=[^;]*', f'Path={target_path}', cookie_header)
@@ -95,18 +91,27 @@ async def proxy_request(
if request.url.query: if request.url.query:
target_url = f"{target_url}?{request.url.query}" target_url = f"{target_url}?{request.url.query}"
is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh"
req_headers: List[Tuple[bytes, bytes]] = [] req_headers: List[Tuple[bytes, bytes]] = []
for raw_k, raw_v in request.headers.raw: for raw_k, raw_v in request.headers.raw:
k_str = raw_k.decode("latin-1").lower() k_str = raw_k.decode("latin-1").lower()
if k_str not in HOP_BY_HOP_HEADERS and k_str != "host": if k_str in HOP_BY_HOP_HEADERS or k_str == "host":
req_headers.append((raw_k, raw_v)) continue
if is_dsh and k_str == "origin":
continue
req_headers.append((raw_k, raw_v))
# Forward original host header info # If DSH backend, replicate localhost:3080 Host/Origin for internal origin check
req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1"))) if is_dsh:
req_headers.append((b"host", b"localhost:3080"))
req_headers.append((b"origin", b"http://localhost:3080"))
else:
req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1")))
req_headers.append((b"x-forwarded-proto", (request.url.scheme or "http").encode("latin-1"))) req_headers.append((b"x-forwarded-proto", (request.url.scheme or "http").encode("latin-1")))
body = await request.body() body = await request.body()
is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}") is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}")
try: try:
@@ -117,10 +122,8 @@ async def proxy_request(
content=body content=body
) )
# Pure stateless async request execution — NO cookie jar retention
upstream_res = await transport.handle_async_request(upstream_req) upstream_res = await transport.handle_async_request(upstream_req)
# Build raw headers list for precise multi-header control
raw_headers: List[Tuple[bytes, bytes]] = [] raw_headers: List[Tuple[bytes, bytes]] = []
media_type = upstream_res.headers.get("content-type") media_type = upstream_res.headers.get("content-type")
@@ -132,11 +135,9 @@ async def proxy_request(
continue continue
if k_str == "set-cookie" and is_path_proxied: if k_str == "set-cookie" and is_path_proxied:
# Path-based proxying: rewrite cookie path
v_str = rewrite_cookie_path(v_str, universe_id) v_str = rewrite_cookie_path(v_str, universe_id)
raw_headers.append((b"set-cookie", v_str.encode("latin-1"))) raw_headers.append((b"set-cookie", v_str.encode("latin-1")))
elif k_str == "location" and is_path_proxied: elif k_str == "location" and is_path_proxied:
# Path-based proxying: rewrite location redirect
if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"): if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"):
v_str = f"/u/{universe_id}{v_str}" v_str = f"/u/{universe_id}{v_str}"
raw_headers.append((b"location", v_str.encode("latin-1"))) raw_headers.append((b"location", v_str.encode("latin-1")))
@@ -161,7 +162,7 @@ async def proxy_request(
except httpx.ConnectError: except httpx.ConnectError:
logger.error(f"Failed to connect to backend at {target_url}") logger.error(f"Failed to connect to backend at {target_url}")
return Response( return Response(
content=f"<html><head><title>Backend Unavailable</title></head><body style='background:#121212;color:#ef4444;font-family:sans-serif;padding:2rem;'><h2>Instance Hermes inaccessible</h2><p>Le backend sur <code>{backend_url}</code> ne répond pas. Vérifiez le tunnel Tailscale ou l'état du conteneur.</p></body></html>", content=f"<html><head><title>Backend Unavailable</title></head><body style='background:#121212;color:#ef4444;font-family:sans-serif;padding:2rem;'><h2>Instance Hermes inaccessible</h2><p>Le backend sur <code>{backend_url}</code> ne répond pas. Vérifiez le réseau interne ou l'état du conteneur.</p></body></html>",
status_code=502, status_code=502,
media_type="text/html" media_type="text/html"
) )
@@ -179,10 +180,6 @@ async def proxy_websocket(
path: str, path: str,
universe_id: Optional[str] = None universe_id: Optional[str] = None
): ):
"""
Bi-directional full duplex WebSocket proxy bridge.
Forwards connection from client browser to backend Hermes universe instance.
"""
ws_base = backend_url.replace("https://", "wss://").replace("http://", "ws://").rstrip("/") ws_base = backend_url.replace("https://", "wss://").replace("http://", "ws://").rstrip("/")
sub_path = path.lstrip("/") sub_path = path.lstrip("/")
upstream_url = f"{ws_base}/{sub_path}" if sub_path else ws_base upstream_url = f"{ws_base}/{sub_path}" if sub_path else ws_base
@@ -190,30 +187,39 @@ async def proxy_websocket(
if client_ws.url.query: if client_ws.url.query:
upstream_url = f"{upstream_url}?{client_ws.url.query}" upstream_url = f"{upstream_url}?{client_ws.url.query}"
is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh"
upstream_headers = {} upstream_headers = {}
for k, v in client_ws.headers.items(): for k, v in client_ws.headers.items():
if k.lower() not in WS_HOP_BY_HOP_HEADERS: if k.lower() not in WS_HOP_BY_HOP_HEADERS:
upstream_headers[k] = v upstream_headers[k] = v
upstream_headers["x-forwarded-host"] = client_ws.headers.get("host", "") if is_dsh:
upstream_headers["x-forwarded-proto"] = client_ws.url.scheme or "http" upstream_headers["host"] = "localhost:3080"
origin_val = "http://localhost:3080"
else:
upstream_headers["x-forwarded-host"] = client_ws.headers.get("host", "")
upstream_headers["x-forwarded-proto"] = client_ws.url.scheme or "http"
origin_val = None
if client_ws.client: if client_ws.client:
upstream_headers["x-forwarded-for"] = client_ws.client.host upstream_headers["x-forwarded-for"] = client_ws.client.host
# Check subprotocols
subprotocols_raw = client_ws.headers.get("sec-websocket-protocol", "") subprotocols_raw = client_ws.headers.get("sec-websocket-protocol", "")
subprotocols = [s.strip() for s in subprotocols_raw.split(",") if s.strip()] or None subprotocols = [s.strip() for s in subprotocols_raw.split(",") if s.strip()] or None
try: try:
async with websockets.connect( connect_kwargs = {
upstream_url, "additional_headers": upstream_headers,
additional_headers=upstream_headers, "subprotocols": subprotocols,
subprotocols=subprotocols, "ping_interval": 20,
ping_interval=20, "ping_timeout": 20,
ping_timeout=20, "max_size": 10 * 1024 * 1024
max_size=10 * 1024 * 1024 }
) as upstream_ws: if origin_val:
# Accept client connection with negotiated subprotocol connect_kwargs["origin"] = origin_val
async with websockets.connect(upstream_url, **connect_kwargs) as upstream_ws:
await client_ws.accept(subprotocol=upstream_ws.subprotocol) await client_ws.accept(subprotocol=upstream_ws.subprotocol)
async def client_to_upstream(): async def client_to_upstream():
+76
View File
@@ -0,0 +1,76 @@
from fastapi import APIRouter, HTTPException, Query, Body, Request
from fastapi.responses import StreamingResponse
from typing import Optional, Dict, Any, List
from pydantic import BaseModel
from app.chat_service import (
list_universe_conversations,
create_universe_conversation,
get_conversation_messages,
stream_chat_messages,
rename_universe_conversation,
pin_universe_conversation,
delete_universe_conversation
)
router = APIRouter(prefix="/api/chat", tags=["Chat"])
class CreateConversationRequest(BaseModel):
title: Optional[str] = "Nouvelle conversation"
class UpdateConversationRequest(BaseModel):
title: Optional[str] = None
pinned: Optional[bool] = None
class SendMessageRequest(BaseModel):
message: str
@router.get("/{universe_id}/conversations")
async def get_conversations(universe_id: str):
"""Returns the list of conversations for a universe."""
convs = await list_universe_conversations(universe_id)
return {"ok": True, "universe_id": universe_id, "conversations": convs}
@router.post("/{universe_id}/conversations")
async def create_conversation(universe_id: str, body: CreateConversationRequest = Body(default_factory=CreateConversationRequest)):
"""Creates a new conversation in a universe."""
conv = await create_universe_conversation(universe_id, title=body.title)
return {"ok": True, "conversation": conv}
@router.get("/{universe_id}/conversations/{session_key}/messages")
async def get_messages(universe_id: str, session_key: str):
"""Retrieves full message history for a conversation."""
messages = await get_conversation_messages(universe_id, session_key)
return {"ok": True, "session_key": session_key, "messages": messages}
@router.post("/{universe_id}/conversations/{session_key}/send")
async def send_message(universe_id: str, session_key: str, body: SendMessageRequest):
"""Sends a message and streams SSE chunks back."""
if not body.message or not body.message.strip():
raise HTTPException(status_code=400, detail="Message cannot be empty")
stream = stream_chat_messages(universe_id, session_key, body.message.strip())
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@router.patch("/{universe_id}/conversations/{session_key}")
async def update_conv(universe_id: str, session_key: str, body: UpdateConversationRequest):
"""Updates conversation title or pinned status."""
if body.title is not None:
await rename_universe_conversation(universe_id, session_key, body.title)
if body.pinned is not None:
await pin_universe_conversation(universe_id, session_key, body.pinned)
return {"ok": True}
@router.delete("/{universe_id}/conversations/{session_key}")
async def delete_conv(universe_id: str, session_key: str):
"""Deletes a conversation."""
await delete_universe_conversation(universe_id, session_key)
return {"ok": True}
+87 -20
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">v1.0</div> <div class="hub-brand-badge">v2.0</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">
@@ -29,6 +29,7 @@
data-scope="{{ u.scope }}" data-scope="{{ u.scope }}"
data-tagline="{{ u.tagline }}" data-tagline="{{ u.tagline }}"
data-supports-files="{{ 'true' if u.supports_files else 'false' }}" data-supports-files="{{ 'true' if u.supports_files else 'false' }}"
data-is-external="{{ 'true' if u.is_external_app else 'false' }}"
title="{{ u.name }} — {{ u.tagline }}" title="{{ u.name }} — {{ u.tagline }}"
style="--item-accent: var({{ u.accent_token }});" style="--item-accent: var({{ u.accent_token }});"
> >
@@ -69,38 +70,104 @@
</div> </div>
<div class="hub-topbar-right"> <div class="hub-topbar-right">
<!-- Sélecteur Toggle Mode DSH (Claude Design - Univers Nabil) --> <!-- Sélecteur Toggle Mode DSH (Univers Nabil) -->
<div id="nabil-mode-toggle" role="tablist" aria-label="Mode du canvas" class="hub-mode-toggle"> <div id="nabil-mode-toggle" role="tablist" aria-label="Mode du canvas" 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" stroke-linecap="square"><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>Session</span> <span>Chat Natif</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" stroke-linecap="square"><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>Explorateur DSH</span> <span>Fichiers DSH</span>
</button> </button>
</div> </div>
<span id="hub-security-badge" style="font-size: 0.75rem; color: var(--hub-text-muted);">Session sécurisée VPS ↔ NAS</span> <span id="hub-security-badge" style="font-size: 0.75rem; color: var(--hub-text-muted);">Session Cloudflare Access Active</span>
</div> </div>
</header> </header>
<!-- Canvas / Iframe Container --> <!-- Canvas Container -->
<div class="hub-canvas"> <div class="hub-canvas">
<!-- Loading Indicator --> <!-- 1. Native Chat Two-Column Interface -->
<div id="hub-loader" class="hub-loader-overlay"> <div id="hub-native-chat" class="hub-native-chat-layout">
<div class="hub-spinner"></div> <!-- Sub-sidebar Conversations -->
<p style="font-size: 0.85rem; color: var(--hub-text-secondary);">Connexion à l'instance Hermes...</p> <div 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>
</div>
<div class="hub-conv-search-wrap">
<input id="hub-conv-search" class="hub-conv-search-input" type="text" placeholder="Filtrer les discussions..." />
</div>
<div id="hub-conv-list" class="hub-conv-list">
<!-- Dynamically populated via JS -->
<div class="hub-conv-loading">Chargement des conversations...</div>
</div>
</div>
<!-- Right Main Chat Pane -->
<div class="hub-chat-pane">
<!-- Active Conversation Header -->
<div class="hub-chat-header">
<div class="hub-chat-header-info">
<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>
<div class="hub-chat-header-actions">
<button id="btn-pin-active" class="hub-btn-icon" title="Épingler cette discussion">📌</button>
<button id="btn-rename-active" class="hub-btn-icon" title="Renommer">✏️</button>
<button id="btn-delete-active" class="hub-btn-icon hub-btn-danger" title="Supprimer la discussion">🗑️</button>
</div>
</div>
<!-- Messages Scroll View -->
<div id="hub-messages-container" class="hub-messages-container">
<div class="hub-empty-state">
<div class="hub-empty-icon">💬</div>
<h3>Discussion avec <span id="empty-state-name">Hermes</span></h3>
<p>Posez une question ou donnez une instruction pour démarrer la session.</p>
</div>
</div>
<!-- Chat Input Footer -->
<div class="hub-chat-input-wrapper">
<form id="hub-chat-form" class="hub-chat-form">
<textarea
id="hub-chat-input"
class="hub-chat-textarea"
placeholder="Envoyer un message à Hermes... (Entrée pour envoyer, Maj+Entrée pour saut de ligne)"
rows="1"
></textarea>
<button id="btn-send-msg" class="hub-btn-send" type="submit" aria-label="Envoyer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
</button>
<button id="btn-stop-msg" class="hub-btn-stop" type="button" style="display: none;" aria-label="Arrêter la réponse">
<div class="hub-stop-square"></div>
</button>
</form>
<div class="hub-chat-input-hint">Hermes Agent Hub • Réponse en streaming direct</div>
</div>
</div>
</div> </div>
<!-- Isolated Workspace View --> <!-- 2. Embedded Iframe View (Used for DSH client and DSH Filebrowser) -->
<iframe <div id="hub-iframe-wrapper" class="hub-iframe-wrapper" style="display: none;">
id="workspace-iframe" <div id="hub-loader" class="hub-loader-overlay">
class="hub-workspace-iframe" <div class="hub-spinner"></div>
src="about:blank" <p style="font-size: 0.85rem; color: var(--hub-text-secondary);">Connexion à l'instance...</p>
title="Hermes Workspace Frame" </div>
></iframe> <iframe
id="workspace-iframe"
class="hub-workspace-iframe"
src="about:blank"
title="Hermes Workspace Frame"
></iframe>
</div>
</div> </div>
</main> </main>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
universe_id: dsh
name: DeepSeek Harness
tagline: Agent Autonome & Trajectoires DSH
tone: Technique, précis, orienté exécution
style: Code, raisonnement pas-à-pas, logs de sous-agents
principles:
- Exécution autonome des sous-agents et vérification stricte
- Gestion des tâches complexes par décomposition
- Supervision des artefacts et fichiers générés
system_prompt: |
Tu es l'environnement DeepSeek Harness (DSH).
Tu pilotes les sous-agents autonomes, les trajectoires d'exécution et les flux de tâches.
skills_active:
- dsh-agents
- code-execution
version: 1
+665 -241
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -40,6 +40,9 @@
--accent-nabil: #8b5cf6; /* Nabil Purple / Gold */ --accent-nabil: #8b5cf6; /* Nabil Purple / Gold */
--accent-nabil-glow: rgba(139, 92, 246, 0.25); --accent-nabil-glow: rgba(139, 92, 246, 0.25);
--accent-dsh: #06b6d4; /* DSH Cyan / DeepSeek */
--accent-dsh-glow: rgba(6, 182, 212, 0.25);
/* Current Active Accent (Dynamically mapped via JS) */ /* Current Active Accent (Dynamically mapped via JS) */
--hub-accent-current: var(--accent-tt); --hub-accent-current: var(--accent-tt);
--hub-accent-glow-current: var(--accent-tt-glow); --hub-accent-glow-current: var(--accent-tt-glow);
@@ -55,4 +58,5 @@
--hub-sidebar-width: 260px; --hub-sidebar-width: 260px;
--hub-sidebar-collapsed-width: 72px; --hub-sidebar-collapsed-width: 72px;
--hub-header-height: 56px; --hub-header-height: 56px;
--hub-conv-sidebar-width: 280px;
} }
+714 -271
View File
File diff suppressed because it is too large Load Diff