Files
hermes-hub/app/chat_service.py
T

335 lines
15 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", "")
# 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