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", "") 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) } 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 } return cookie_str async def get_nabil_ws_ticket() -> str: """Acquires a single-use WebSocket ticket from Nabil.""" cookie = await get_nabil_session_cookie() async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.post( "http://hermes-nabil:9119/api/auth/ws-ticket", json={}, headers={"Cookie": cookie} ) if resp.status_code == 200: return resp.json().get("ticket", "") return "" async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]: """Lists conversations for a given universe.""" if universe_id == "nabil": 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[: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) save_conversation("nabil", sid, title=stitle, pinned=spinned, created_at=c_at) except Exception: pass return list_conversations("nabil") elif universe_id in ("tt", "nyora", "perso"): 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 on backend 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": cookie = await get_nabil_session_cookie() ticket = await get_nabil_ws_ticket() session_title = title or "Nouvelle conversation" session_key = None ws_url = f"ws://hermes-nabil:9119/api/ws?ticket={ticket}" try: async with websockets.connect( ws_url, additional_headers={"Cookie": cookie}, ping_interval=20, ping_timeout=20 ) as ws: # 1. session.create req_id = f"create-{uuid.uuid4().hex[:8]}" await ws.send(json.dumps({ "jsonrpc": "2.0", "id": req_id, "method": "session.create", "params": {"title": session_title} })) short_id = None while True: raw = await asyncio.wait_for(ws.recv(), timeout=6.0) data = json.loads(raw) if data.get("id") == req_id and "result" in data: res = data["result"] short_id = res.get("session_id") session_key = res.get("stored_session_id") or short_id break # 2. session.title (persists the row immediately to state.db under stored_session_id) if short_id: req_t_id = f"title-{uuid.uuid4().hex[:8]}" await ws.send(json.dumps({ "jsonrpc": "2.0", "id": req_t_id, "method": "session.title", "params": {"session_id": short_id, "title": session_title} })) while True: raw_t = await asyncio.wait_for(ws.recv(), timeout=6.0) data_t = json.loads(raw_t) if data_t.get("id") == req_t_id: break except Exception: session_key = f"nabil_{uuid.uuid4().hex[:12]}" conv = save_conversation(universe_id, session_key, title=session_title) 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 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).""" 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" save_conversation(universe_id, session_key) elif universe_id == "nabil": cookie = await get_nabil_session_cookie() ticket = await get_nabil_ws_ticket() 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}, 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 while True: raw_res = await asyncio.wait_for(ws.recv(), timeout=6.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 # 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": active_sid, "text": message } } await ws.send(json.dumps(send_payload)) accumulated_text = "" while True: try: raw = await asyncio.wait_for(ws.recv(), timeout=60.0) data = json.loads(raw) params = data.get("params", {}) ev_type = params.get("type", "") payload = params.get("payload", {}) if ev_type in ("message.delta", "chat.chunk"): delta = payload.get("text") or params.get("text") or "" accumulated_text += delta 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", "") 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 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: 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: 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: 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