335 lines
15 KiB
Python
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
|