fix(phase3): fix filebrowser tab url, implement nabil session.create/prompt.submit rpc, and fix dsh websocket same-origin bridge
This commit is contained in:
+70
-36
@@ -43,7 +43,6 @@ async def get_workspace_cookie(universe_id: str) -> str:
|
|||||||
raise RuntimeError(f"Failed to authenticate with {universe_id}: {resp.status_code} {resp.text}")
|
raise RuntimeError(f"Failed to authenticate with {universe_id}: {resp.status_code} {resp.text}")
|
||||||
|
|
||||||
cookie_header = resp.headers.get("set-cookie", "")
|
cookie_header = resp.headers.get("set-cookie", "")
|
||||||
# Extract claude-auth cookie
|
|
||||||
token_part = ""
|
token_part = ""
|
||||||
for part in cookie_header.split(";"):
|
for part in cookie_header.split(";"):
|
||||||
if "claude-auth=" in part:
|
if "claude-auth=" in part:
|
||||||
@@ -55,7 +54,7 @@ async def get_workspace_cookie(universe_id: str) -> str:
|
|||||||
cookie_str = token_part or cookie_header.split(";")[0].strip()
|
cookie_str = token_part or cookie_header.split(";")[0].strip()
|
||||||
_AUTH_COOKIES[universe_id] = {
|
_AUTH_COOKIES[universe_id] = {
|
||||||
"cookie": cookie_str,
|
"cookie": cookie_str,
|
||||||
"expires_at": now + (25 * 86400) # 25 days validity
|
"expires_at": now + (25 * 86400)
|
||||||
}
|
}
|
||||||
return cookie_str
|
return cookie_str
|
||||||
|
|
||||||
@@ -85,14 +84,26 @@ async def get_nabil_session_cookie() -> str:
|
|||||||
cookie_str = "; ".join(cookie_parts)
|
cookie_str = "; ".join(cookie_parts)
|
||||||
_AUTH_COOKIES["nabil"] = {
|
_AUTH_COOKIES["nabil"] = {
|
||||||
"cookie": cookie_str,
|
"cookie": cookie_str,
|
||||||
"expires_at": now + 3600 # 1 hour
|
"expires_at": now + 3600
|
||||||
}
|
}
|
||||||
return cookie_str
|
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]]:
|
async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]:
|
||||||
"""Lists conversations for a given universe."""
|
"""Lists conversations for a given universe."""
|
||||||
if universe_id == "nabil":
|
if universe_id == "nabil":
|
||||||
# Sync from Nabil backend and merge with local SQLite store
|
|
||||||
try:
|
try:
|
||||||
cookie = await get_nabil_session_cookie()
|
cookie = await get_nabil_session_cookie()
|
||||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||||
@@ -108,18 +119,16 @@ async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]:
|
|||||||
started_at = s.get("started_at")
|
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)
|
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)
|
save_conversation("nabil", sid, title=stitle, pinned=spinned, created_at=c_at)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Fallback to local store if backend unreachable
|
|
||||||
pass
|
pass
|
||||||
return list_conversations("nabil")
|
return list_conversations("nabil")
|
||||||
elif universe_id in ("tt", "nyora", "perso"):
|
elif universe_id in ("tt", "nyora", "perso"):
|
||||||
# For workspace backends, SQLite is the canonical index
|
|
||||||
return list_conversations(universe_id)
|
return list_conversations(universe_id)
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def create_universe_conversation(universe_id: str, title: Optional[str] = None) -> Dict[str, Any]:
|
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."""
|
"""Creates a new conversation on backend and indexes it in Hub SQLite."""
|
||||||
if universe_id in ("tt", "nyora", "perso"):
|
if universe_id in ("tt", "nyora", "perso"):
|
||||||
cookie = await get_workspace_cookie(universe_id)
|
cookie = await get_workspace_cookie(universe_id)
|
||||||
universe = UNIVERSES[universe_id]
|
universe = UNIVERSES[universe_id]
|
||||||
@@ -134,8 +143,41 @@ async def create_universe_conversation(universe_id: str, title: Optional[str] =
|
|||||||
conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation")
|
conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation")
|
||||||
return conv
|
return conv
|
||||||
elif universe_id == "nabil":
|
elif universe_id == "nabil":
|
||||||
session_key = str(uuid.uuid4())
|
# Create session explicitly on hermes-nabil backend via session.create RPC
|
||||||
conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation")
|
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:
|
||||||
|
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}
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Wait for session.create result frame
|
||||||
|
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"]
|
||||||
|
session_key = res.get("session_id") or res.get("stored_session_id")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback if WS fails
|
||||||
|
session_key = f"nabil_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
conv = save_conversation(universe_id, session_key, title=session_title)
|
||||||
return conv
|
return conv
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown universe {universe_id}")
|
raise ValueError(f"Unknown universe {universe_id}")
|
||||||
@@ -176,7 +218,6 @@ async def get_conversation_messages(universe_id: str, session_key: str) -> List[
|
|||||||
normalized = []
|
normalized = []
|
||||||
for m in raw_messages:
|
for m in raw_messages:
|
||||||
content = m.get("content", "")
|
content = m.get("content", "")
|
||||||
# If assistant only has tool calls, present readable summary
|
|
||||||
if not content and m.get("tool_calls"):
|
if not content and m.get("tool_calls"):
|
||||||
tool_names = [tc.get("function", {}).get("name", "tool") for tc in 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)}]"
|
content = f"[Outils exécutés : {', '.join(tool_names)}]"
|
||||||
@@ -215,15 +256,11 @@ async def stream_chat_messages(universe_id: str, session_key: str, message: str)
|
|||||||
yield f"{line}\n"
|
yield f"{line}\n"
|
||||||
else:
|
else:
|
||||||
yield "\n"
|
yield "\n"
|
||||||
# Touch updated_at
|
|
||||||
save_conversation(universe_id, session_key)
|
save_conversation(universe_id, session_key)
|
||||||
|
|
||||||
elif universe_id == "nabil":
|
elif universe_id == "nabil":
|
||||||
cookie = await get_nabil_session_cookie()
|
cookie = await get_nabil_session_cookie()
|
||||||
# Acquire ws ticket
|
ticket = await get_nabil_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"
|
yield "event: started\ndata: {\"status\": \"connecting\"}\n\n"
|
||||||
|
|
||||||
@@ -231,17 +268,18 @@ async def stream_chat_messages(universe_id: str, session_key: str, message: str)
|
|||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
ws_url,
|
ws_url,
|
||||||
additional_headers={"Cookie": cookie}
|
additional_headers={"Cookie": cookie},
|
||||||
|
ping_interval=20,
|
||||||
|
ping_timeout=20
|
||||||
) as ws:
|
) as ws:
|
||||||
# Send chat message via JSON-RPC
|
req_id = f"prompt-{uuid.uuid4().hex[:8]}"
|
||||||
req_id = str(uuid.uuid4())
|
|
||||||
send_payload = {
|
send_payload = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req_id,
|
"id": req_id,
|
||||||
"method": "chat.send",
|
"method": "prompt.submit",
|
||||||
"params": {
|
"params": {
|
||||||
"session_id": session_key,
|
"session_id": session_key,
|
||||||
"message": message
|
"text": message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await ws.send(json.dumps(send_payload))
|
await ws.send(json.dumps(send_payload))
|
||||||
@@ -249,24 +287,23 @@ async def stream_chat_messages(universe_id: str, session_key: str, message: str)
|
|||||||
accumulated_text = ""
|
accumulated_text = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=45.0)
|
raw = await asyncio.wait_for(ws.recv(), timeout=60.0)
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
|
|
||||||
method = data.get("method")
|
|
||||||
params = data.get("params", {})
|
params = data.get("params", {})
|
||||||
|
ev_type = params.get("type", "")
|
||||||
|
payload = params.get("payload", {})
|
||||||
|
|
||||||
if method == "chat.chunk":
|
if ev_type in ("message.delta", "chat.chunk"):
|
||||||
chunk_text = params.get("text", "")
|
delta = payload.get("text") or params.get("text") or ""
|
||||||
accumulated_text += chunk_text
|
accumulated_text += delta
|
||||||
yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': chunk_text, 'fullReplace': True})}\n\n"
|
yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': delta, 'fullReplace': True})}\n\n"
|
||||||
elif method in ("chat.done", "session.done", "gateway.ready"):
|
elif ev_type in ("reasoning.delta", "thinking.delta"):
|
||||||
if method != "gateway.ready":
|
delta = payload.get("text", "")
|
||||||
|
# We can stream reasoning as needed
|
||||||
|
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"
|
yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': accumulated_text})}\n\n"
|
||||||
break
|
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:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -276,7 +313,6 @@ async def stream_chat_messages(universe_id: str, session_key: str, message: str)
|
|||||||
save_conversation(universe_id, session_key)
|
save_conversation(universe_id, session_key)
|
||||||
|
|
||||||
async def rename_universe_conversation(universe_id: str, session_key: str, title: str) -> bool:
|
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)
|
update_conversation(universe_id, session_key, title=title)
|
||||||
if universe_id == "nabil":
|
if universe_id == "nabil":
|
||||||
try:
|
try:
|
||||||
@@ -292,7 +328,6 @@ async def rename_universe_conversation(universe_id: str, session_key: str, title
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def pin_universe_conversation(universe_id: str, session_key: str, pinned: bool) -> bool:
|
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)
|
update_conversation(universe_id, session_key, pinned=pinned)
|
||||||
if universe_id == "nabil":
|
if universe_id == "nabil":
|
||||||
try:
|
try:
|
||||||
@@ -308,7 +343,6 @@ async def pin_universe_conversation(universe_id: str, session_key: str, pinned:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def delete_universe_conversation(universe_id: str, session_key: str) -> bool:
|
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)
|
delete_conversation_record(universe_id, session_key)
|
||||||
if universe_id in ("tt", "nyora", "perso"):
|
if universe_id in ("tt", "nyora", "perso"):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+27
-13
@@ -1,5 +1,6 @@
|
|||||||
import httpx
|
import httpx
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import AsyncGenerator, Dict, Any, Optional, List, Tuple
|
from typing import AsyncGenerator, Dict, Any, Optional, List, Tuple
|
||||||
from fastapi import Request, Response, WebSocket, WebSocketDisconnect
|
from fastapi import Request, Response, WebSocket, WebSocketDisconnect
|
||||||
@@ -30,7 +31,9 @@ WS_HOP_BY_HOP_HEADERS = {
|
|||||||
"sec-websocket-extensions",
|
"sec-websocket-extensions",
|
||||||
"sec-websocket-accept",
|
"sec-websocket-accept",
|
||||||
"host",
|
"host",
|
||||||
"origin"
|
"origin",
|
||||||
|
"sec-fetch-site",
|
||||||
|
"sec-fetch-mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
_http_transport: Optional[httpx.AsyncHTTPTransport] = None
|
_http_transport: Optional[httpx.AsyncHTTPTransport] = None
|
||||||
@@ -98,14 +101,15 @@ async def proxy_request(
|
|||||||
k_str = raw_k.decode("latin-1").lower()
|
k_str = raw_k.decode("latin-1").lower()
|
||||||
if k_str in HOP_BY_HOP_HEADERS or k_str == "host":
|
if k_str in HOP_BY_HOP_HEADERS or k_str == "host":
|
||||||
continue
|
continue
|
||||||
if is_dsh and k_str == "origin":
|
if is_dsh and k_str in ("origin", "sec-fetch-site"):
|
||||||
continue
|
continue
|
||||||
req_headers.append((raw_k, raw_v))
|
req_headers.append((raw_k, raw_v))
|
||||||
|
|
||||||
# If DSH backend, replicate localhost:3080 Host/Origin for internal origin check
|
# If DSH backend, replicate localhost:3080 Host/Origin and same-origin site
|
||||||
if is_dsh:
|
if is_dsh:
|
||||||
req_headers.append((b"host", b"localhost:3080"))
|
req_headers.append((b"host", b"localhost:3080"))
|
||||||
req_headers.append((b"origin", b"http://localhost:3080"))
|
req_headers.append((b"origin", b"http://localhost:3080"))
|
||||||
|
req_headers.append((b"sec-fetch-site", b"same-origin"))
|
||||||
else:
|
else:
|
||||||
req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1")))
|
req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1")))
|
||||||
|
|
||||||
@@ -180,13 +184,7 @@ async def proxy_websocket(
|
|||||||
path: str,
|
path: str,
|
||||||
universe_id: Optional[str] = None
|
universe_id: Optional[str] = None
|
||||||
):
|
):
|
||||||
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
|
|
||||||
|
|
||||||
if 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"
|
is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh"
|
||||||
|
|
||||||
upstream_headers = {}
|
upstream_headers = {}
|
||||||
@@ -194,10 +192,27 @@ async def proxy_websocket(
|
|||||||
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
|
||||||
|
|
||||||
|
subprotocols_raw = client_ws.headers.get("sec-websocket-protocol", "")
|
||||||
|
subprotocols = [s.strip() for s in subprotocols_raw.split(",") if s.strip()] or None
|
||||||
|
|
||||||
|
sock = None
|
||||||
if is_dsh:
|
if is_dsh:
|
||||||
upstream_headers["host"] = "localhost:3080"
|
# DSH requires exact Host: localhost:3080, Origin: http://localhost:3080 and Sec-Fetch-Site: same-origin
|
||||||
|
upstream_headers["Sec-Fetch-Site"] = "same-origin"
|
||||||
|
upstream_url = f"ws://localhost:3080/{sub_path}" if sub_path else "ws://localhost:3080"
|
||||||
|
if client_ws.url.query:
|
||||||
|
upstream_url = f"{upstream_url}?{client_ws.url.query}"
|
||||||
|
|
||||||
|
# Connect raw socket directly to dsh-vps:3080
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect(("dsh-vps", 3080))
|
||||||
|
sock.setblocking(False)
|
||||||
origin_val = "http://localhost:3080"
|
origin_val = "http://localhost:3080"
|
||||||
else:
|
else:
|
||||||
|
ws_base = backend_url.replace("https://", "wss://").replace("http://", "ws://").rstrip("/")
|
||||||
|
upstream_url = f"{ws_base}/{sub_path}" if sub_path else ws_base
|
||||||
|
if client_ws.url.query:
|
||||||
|
upstream_url = f"{upstream_url}?{client_ws.url.query}"
|
||||||
upstream_headers["x-forwarded-host"] = client_ws.headers.get("host", "")
|
upstream_headers["x-forwarded-host"] = client_ws.headers.get("host", "")
|
||||||
upstream_headers["x-forwarded-proto"] = client_ws.url.scheme or "http"
|
upstream_headers["x-forwarded-proto"] = client_ws.url.scheme or "http"
|
||||||
origin_val = None
|
origin_val = None
|
||||||
@@ -205,9 +220,6 @@ async def proxy_websocket(
|
|||||||
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
|
||||||
|
|
||||||
subprotocols_raw = client_ws.headers.get("sec-websocket-protocol", "")
|
|
||||||
subprotocols = [s.strip() for s in subprotocols_raw.split(",") if s.strip()] or None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connect_kwargs = {
|
connect_kwargs = {
|
||||||
"additional_headers": upstream_headers,
|
"additional_headers": upstream_headers,
|
||||||
@@ -216,6 +228,8 @@ async def proxy_websocket(
|
|||||||
"ping_timeout": 20,
|
"ping_timeout": 20,
|
||||||
"max_size": 10 * 1024 * 1024
|
"max_size": 10 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
if sock:
|
||||||
|
connect_kwargs["sock"] = sock
|
||||||
if origin_val:
|
if origin_val:
|
||||||
connect_kwargs["origin"] = origin_val
|
connect_kwargs["origin"] = origin_val
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -630,7 +630,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
tabFiles.addEventListener('click', () => {
|
tabFiles.addEventListener('click', () => {
|
||||||
tabFiles.classList.add('active');
|
tabFiles.classList.add('active');
|
||||||
tabSession.classList.remove('active');
|
tabSession.classList.remove('active');
|
||||||
showIframeView(`https://dsh-hub.yesminedor.tn/`);
|
showIframeView(`https://files-hub.yesminedor.tn/`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user