feat(chat): implement native hub chat with sqlite conversations store, SSE streaming and DSH 5th universe integration
This commit is contained in:
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user