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"))
|
||||
PERSONAS_DIR = DATA_DIR / "personas"
|
||||
CLONES_DIR = DATA_DIR / "clones"
|
||||
DB_PATH = DATA_DIR / "hub.db"
|
||||
|
||||
class UniverseConfig(BaseModel):
|
||||
id: str
|
||||
@@ -22,6 +23,7 @@ class UniverseConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
supports_files: bool = False
|
||||
files_url: Optional[str] = None
|
||||
is_external_app: bool = False
|
||||
|
||||
class Settings(BaseSettings):
|
||||
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_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_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")
|
||||
|
||||
# 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: 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",
|
||||
icon="briefcase",
|
||||
persona_file="tt.yaml",
|
||||
supports_files=False
|
||||
supports_files=False,
|
||||
is_external_app=False
|
||||
),
|
||||
"nyora": UniverseConfig(
|
||||
id="nyora",
|
||||
@@ -84,7 +95,8 @@ UNIVERSES: Dict[str, UniverseConfig] = {
|
||||
accent_token="--accent-nyora",
|
||||
icon="sparkles",
|
||||
persona_file="nyora.yaml",
|
||||
supports_files=False
|
||||
supports_files=False,
|
||||
is_external_app=False
|
||||
),
|
||||
"perso": UniverseConfig(
|
||||
id="perso",
|
||||
@@ -96,20 +108,35 @@ UNIVERSES: Dict[str, UniverseConfig] = {
|
||||
accent_token="--accent-perso",
|
||||
icon="home",
|
||||
persona_file="perso.yaml",
|
||||
supports_files=False
|
||||
supports_files=False,
|
||||
is_external_app=False
|
||||
),
|
||||
"nabil": UniverseConfig(
|
||||
id="nabil",
|
||||
name="Nabil Master",
|
||||
tagline="Orchestration & DSH",
|
||||
description="Master Agent VPS, exécution de code & DeepSeek Harness",
|
||||
tagline="Orchestration & Code",
|
||||
description="Master Agent VPS, exécution de code & supervision DSH",
|
||||
backend_url=os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119"),
|
||||
scope="nabil",
|
||||
accent_token="--accent-nabil",
|
||||
icon="terminal",
|
||||
persona_file="nabil.yaml",
|
||||
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
|
||||
+7
-3
@@ -7,9 +7,10 @@ from typing import Optional, Tuple
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
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.personas import ensure_dirs
|
||||
from app.db import init_db
|
||||
|
||||
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')
|
||||
perso-hub.yesminedor.tn -> (HERMES_PERSO_URL, 'perso')
|
||||
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:
|
||||
hub.yesminedor.tn -> None (Serves index.html Workspace Switcher)
|
||||
@@ -51,7 +53,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]:
|
||||
if not sub or sub in ("hub", "www"):
|
||||
return None
|
||||
|
||||
if sub in ("dsh", "files", "dsh-files"):
|
||||
if sub in ("files", "dsh-files"):
|
||||
return (settings.dsh_filebrowser_url, "nabil")
|
||||
|
||||
if sub in UNIVERSES:
|
||||
@@ -62,6 +64,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
ensure_dirs()
|
||||
init_db()
|
||||
yield
|
||||
await close_http_client()
|
||||
|
||||
@@ -99,6 +102,7 @@ templates = Jinja2Templates(directory=str(templates_dir))
|
||||
# Include Routers
|
||||
app.include_router(api.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(chat.router)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@app.head("/", response_class=HTMLResponse)
|
||||
|
||||
+32
-26
@@ -51,10 +51,6 @@ async def close_http_client():
|
||||
_http_transport = None
|
||||
|
||||
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}/"
|
||||
if re.search(r'(?i)\bpath=[^;]*', 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:
|
||||
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]] = []
|
||||
for raw_k, raw_v in request.headers.raw:
|
||||
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":
|
||||
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
|
||||
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")))
|
||||
|
||||
body = await request.body()
|
||||
|
||||
is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}")
|
||||
|
||||
try:
|
||||
@@ -117,10 +122,8 @@ async def proxy_request(
|
||||
content=body
|
||||
)
|
||||
|
||||
# Pure stateless async request execution — NO cookie jar retention
|
||||
upstream_res = await transport.handle_async_request(upstream_req)
|
||||
|
||||
# Build raw headers list for precise multi-header control
|
||||
raw_headers: List[Tuple[bytes, bytes]] = []
|
||||
media_type = upstream_res.headers.get("content-type")
|
||||
|
||||
@@ -132,11 +135,9 @@ async def proxy_request(
|
||||
continue
|
||||
|
||||
if k_str == "set-cookie" and is_path_proxied:
|
||||
# Path-based proxying: rewrite cookie path
|
||||
v_str = rewrite_cookie_path(v_str, universe_id)
|
||||
raw_headers.append((b"set-cookie", v_str.encode("latin-1")))
|
||||
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}"):
|
||||
v_str = f"/u/{universe_id}{v_str}"
|
||||
raw_headers.append((b"location", v_str.encode("latin-1")))
|
||||
@@ -161,7 +162,7 @@ async def proxy_request(
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Failed to connect to backend at {target_url}")
|
||||
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,
|
||||
media_type="text/html"
|
||||
)
|
||||
@@ -179,10 +180,6 @@ async def proxy_websocket(
|
||||
path: str,
|
||||
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("/")
|
||||
sub_path = path.lstrip("/")
|
||||
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:
|
||||
upstream_url = f"{upstream_url}?{client_ws.url.query}"
|
||||
|
||||
is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh"
|
||||
|
||||
upstream_headers = {}
|
||||
for k, v in client_ws.headers.items():
|
||||
if k.lower() not in WS_HOP_BY_HOP_HEADERS:
|
||||
upstream_headers[k] = v
|
||||
|
||||
if is_dsh:
|
||||
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:
|
||||
upstream_headers["x-forwarded-for"] = client_ws.client.host
|
||||
|
||||
# Check subprotocols
|
||||
subprotocols_raw = client_ws.headers.get("sec-websocket-protocol", "")
|
||||
subprotocols = [s.strip() for s in subprotocols_raw.split(",") if s.strip()] or None
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
upstream_url,
|
||||
additional_headers=upstream_headers,
|
||||
subprotocols=subprotocols,
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
max_size=10 * 1024 * 1024
|
||||
) as upstream_ws:
|
||||
# Accept client connection with negotiated subprotocol
|
||||
connect_kwargs = {
|
||||
"additional_headers": upstream_headers,
|
||||
"subprotocols": subprotocols,
|
||||
"ping_interval": 20,
|
||||
"ping_timeout": 20,
|
||||
"max_size": 10 * 1024 * 1024
|
||||
}
|
||||
if origin_val:
|
||||
connect_kwargs["origin"] = origin_val
|
||||
|
||||
async with websockets.connect(upstream_url, **connect_kwargs) as upstream_ws:
|
||||
await client_ws.accept(subprotocol=upstream_ws.subprotocol)
|
||||
|
||||
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}
|
||||
+81
-14
@@ -8,7 +8,7 @@
|
||||
<div class="hub-brand-left">
|
||||
<div class="hub-logo-icon">H</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>
|
||||
<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">
|
||||
@@ -29,6 +29,7 @@
|
||||
data-scope="{{ u.scope }}"
|
||||
data-tagline="{{ u.tagline }}"
|
||||
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 }}"
|
||||
style="--item-accent: var({{ u.accent_token }});"
|
||||
>
|
||||
@@ -69,32 +70,97 @@
|
||||
</div>
|
||||
|
||||
<div class="hub-topbar-right">
|
||||
<!-- Sélecteur Toggle Mode DSH (Claude Design - Univers Nabil) -->
|
||||
<div id="nabil-mode-toggle" role="tablist" aria-label="Mode du canvas" class="hub-mode-toggle">
|
||||
<!-- Sélecteur Toggle Mode DSH (Univers Nabil) -->
|
||||
<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">
|
||||
<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>
|
||||
<span>Session</span>
|
||||
<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>Chat Natif</span>
|
||||
</button>
|
||||
<div class="hub-mode-divider"></div>
|
||||
<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>
|
||||
<span>Explorateur DSH</span>
|
||||
<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>Fichiers DSH</span>
|
||||
</button>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<!-- Canvas / Iframe Container -->
|
||||
<!-- Canvas Container -->
|
||||
<div class="hub-canvas">
|
||||
<!-- Loading Indicator -->
|
||||
<div id="hub-loader" class="hub-loader-overlay">
|
||||
<div class="hub-spinner"></div>
|
||||
<p style="font-size: 0.85rem; color: var(--hub-text-secondary);">Connexion à l'instance Hermes...</p>
|
||||
<!-- 1. Native Chat Two-Column Interface -->
|
||||
<div id="hub-native-chat" class="hub-native-chat-layout">
|
||||
<!-- Sub-sidebar Conversations -->
|
||||
<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>
|
||||
|
||||
<!-- Isolated Workspace View -->
|
||||
<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>
|
||||
|
||||
<!-- 2. Embedded Iframe View (Used for DSH client and DSH Filebrowser) -->
|
||||
<div id="hub-iframe-wrapper" class="hub-iframe-wrapper" style="display: none;">
|
||||
<div id="hub-loader" class="hub-loader-overlay">
|
||||
<div class="hub-spinner"></div>
|
||||
<p style="font-size: 0.85rem; color: var(--hub-text-secondary);">Connexion à l'instance...</p>
|
||||
</div>
|
||||
<iframe
|
||||
id="workspace-iframe"
|
||||
class="hub-workspace-iframe"
|
||||
@@ -102,6 +168,7 @@
|
||||
title="Hermes Workspace Frame"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</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-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) */
|
||||
--hub-accent-current: var(--accent-tt);
|
||||
--hub-accent-glow-current: var(--accent-tt-glow);
|
||||
@@ -55,4 +58,5 @@
|
||||
--hub-sidebar-width: 260px;
|
||||
--hub-sidebar-collapsed-width: 72px;
|
||||
--hub-header-height: 56px;
|
||||
--hub-conv-sidebar-width: 280px;
|
||||
}
|
||||
|
||||
+706
-263
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user