Files

402 lines
17 KiB
Python

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