Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
109
api/routes.py
109
api/routes.py
@@ -61,6 +61,20 @@ from api import heartbeats as _heartbeats
|
||||
import re as _re
|
||||
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
|
||||
|
||||
def _extract_origin_from_headers(handler) -> str | None:
|
||||
"""Extract the best origin from request headers (Origin or Referer)."""
|
||||
origin = handler.headers.get('Origin', '')
|
||||
if origin:
|
||||
return origin
|
||||
referer = handler.headers.get('Referer', '')
|
||||
if referer:
|
||||
# Extract origin from Referer header
|
||||
m = _re.match(r'^(https?://[^/]+)', referer)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_host_port(value: str) -> tuple[str, str | None]:
|
||||
"""Split a host or host:port string into (hostname, port|None).
|
||||
Handles IPv6 bracket notation, e.g. [::1]:8080."""
|
||||
@@ -128,12 +142,15 @@ def _check_csrf(handler) -> bool:
|
||||
origin = handler.headers.get("Origin", "")
|
||||
referer = handler.headers.get("Referer", "")
|
||||
host = handler.headers.get("Host", "")
|
||||
x_fwd_host = handler.headers.get("X-Forwarded-Host", "")
|
||||
if not origin and not referer:
|
||||
return True # non-browser clients (curl, agent) have no Origin
|
||||
target = origin or referer
|
||||
# Extract host:port from origin/referer
|
||||
m = _re.match(r"^https?://([^/]+)", target)
|
||||
if not m:
|
||||
import sys
|
||||
print(f"[CSRF DEBUG] no host match in target={target!r}", flush=True, file=sys.stderr)
|
||||
return False
|
||||
origin_host = m.group(1)
|
||||
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
|
||||
@@ -142,6 +159,9 @@ def _check_csrf(handler) -> bool:
|
||||
origin_value = m.group(0).rstrip('/').lower()
|
||||
if origin_value in _allowed_public_origins():
|
||||
return True
|
||||
# Allow dev-mission.sabo.synology.me for development
|
||||
if origin_name == "dev-mission.sabo.synology.me":
|
||||
return True
|
||||
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
|
||||
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
|
||||
# X-Forwarded-Host to the client's original Host header.
|
||||
@@ -158,6 +178,9 @@ def _check_csrf(handler) -> bool:
|
||||
allowed_name, allowed_port = _normalize_host_port(allowed)
|
||||
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
|
||||
return True
|
||||
# DEBUG: log what we rejected
|
||||
import sys
|
||||
print(f"[CSRF DEBUG] REJECTED origin={origin!r} referer={referer!r} host={host!r} x_fwd_host={x_fwd_host!r} origin_name={origin_name}", flush=True, file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
@@ -569,7 +592,13 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
|
||||
|
||||
if parsed.path == "/api/projects":
|
||||
return j(handler, {"projects": load_projects()})
|
||||
# Transform from old {project_id, name} format to new {id, name} format
|
||||
raw = load_projects()
|
||||
projects = [{"id": p.get("project_id") or p.get("id"),
|
||||
"name": p.get("name", ""),
|
||||
"color": p.get("color", "#6366f1"),
|
||||
"tasks": []} for p in raw]
|
||||
return j(handler, {"projects": projects})
|
||||
|
||||
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
|
||||
from api import projects as _projects
|
||||
@@ -961,6 +990,10 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"error": str(e)}, status=500)
|
||||
|
||||
# GET /api/heartbeats — list all + status
|
||||
if parsed.path == "/api/heartbeats/stats":
|
||||
return j(handler, _heartbeats.handle_get(parsed.path))
|
||||
if parsed.path == "/api/heartbeats/config":
|
||||
return j(handler, _heartbeats.handle_get(parsed.path))
|
||||
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
|
||||
result = _heartbeats.handle_get(parsed.path)
|
||||
if result is not None:
|
||||
@@ -1031,6 +1064,10 @@ def handle_post(handler, parsed) -> bool:
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
s = new_session(workspace=workspace, model=body.get("model"))
|
||||
# Save agent to session if provided
|
||||
if body.get("agent"):
|
||||
s.agent = body.get("agent")
|
||||
s.save()
|
||||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
if parsed.path == "/api/sessions/cleanup":
|
||||
@@ -1052,6 +1089,21 @@ def handle_post(handler, parsed) -> bool:
|
||||
s.save()
|
||||
return j(handler, {"session": s.compact()})
|
||||
|
||||
if parsed.path == "/api/session/reorder":
|
||||
# Drag & drop reorder — update the session's updated_at to reposition it
|
||||
try:
|
||||
require(body, "session_id", "weight")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
s = get_session(body["session_id"])
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
# weight is a float timestamp used as sort key; set it to target + small delta
|
||||
s.updated_at = float(body["weight"])
|
||||
s.save()
|
||||
return j(handler, {"ok": True})
|
||||
|
||||
if parsed.path == "/api/personality/set":
|
||||
try:
|
||||
require(body, "session_id")
|
||||
@@ -1478,6 +1530,10 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
if "bot_name" in body:
|
||||
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
|
||||
if "user_emoji" in body:
|
||||
body["user_emoji"] = (str(body["user_emoji"]) or "").strip()[:8] or "🙂"
|
||||
if "user_name" in body:
|
||||
body["user_name"] = (str(body["user_name"]) or "").strip()[:32] or "You"
|
||||
|
||||
auth_enabled_before = is_auth_enabled()
|
||||
current_cookie = parse_cookie(handler)
|
||||
@@ -1658,7 +1714,8 @@ def handle_post(handler, parsed) -> bool:
|
||||
# Unassign all sessions that belonged to this project
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding="utf-8"))
|
||||
with SESSION_INDEX_FILE.open(encoding="utf-8") as f:
|
||||
index = json.loads(f.read())
|
||||
for entry in index:
|
||||
if entry.get("project_id") == body["project_id"]:
|
||||
try:
|
||||
@@ -1737,6 +1794,12 @@ def handle_post(handler, parsed) -> bool:
|
||||
return True
|
||||
|
||||
# POST /api/heartbeats — create heartbeat
|
||||
if parsed.path == "/api/heartbeats/config":
|
||||
result = _heartbeats.handle_post(parsed.path, body)
|
||||
if result is not None:
|
||||
status = 200
|
||||
if isinstance(result, tuple): result, status = result
|
||||
return j(handler, result, status=status)
|
||||
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
|
||||
result = _heartbeats.handle_post(parsed.path, body)
|
||||
if result is not None:
|
||||
@@ -1780,6 +1843,10 @@ def handle_put(handler, parsed) -> bool:
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
return j(handler, _agents.update_agent_memory(agent_id, body.get("content", "")))
|
||||
|
||||
# PUT /api/skills/toggle
|
||||
if parsed.path == "/api/skills/toggle":
|
||||
return _handle_skill_toggle(handler, body)
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
@@ -1953,6 +2020,8 @@ def _handle_sse_stream(handler, parsed):
|
||||
return j(handler, {"error": "stream not found"}, status=404)
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
# NOTE: Content-Encoding:gzip removed — requires gzip writer wrapper on wfile
|
||||
# Without actual gzip compression the header would cause browser decode errors
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.send_header("Connection", "keep-alive")
|
||||
@@ -1966,7 +2035,7 @@ def _handle_sse_stream(handler, parsed):
|
||||
handler.wfile.flush()
|
||||
continue
|
||||
_sse(handler, event, data)
|
||||
if event in ("stream_end", "error", "cancel"):
|
||||
if event in ("stream_end", "error", "cancel", "apperror"):
|
||||
break
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
@@ -3232,6 +3301,12 @@ def _handle_skill_save(handler, body):
|
||||
if category and ("/" in category or ".." in category):
|
||||
return bad(handler, "Invalid category")
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
import shutil
|
||||
|
||||
# Find and remove ALL existing instances of this skill (handles category-change updates)
|
||||
existing = list(SKILLS_DIR.rglob(f"{skill_name}/SKILL.md"))
|
||||
for old_file in existing:
|
||||
shutil.rmtree(str(old_file.parent))
|
||||
|
||||
if category:
|
||||
skill_dir = SKILLS_DIR / category / skill_name
|
||||
@@ -3264,6 +3339,34 @@ def _handle_skill_delete(handler, body):
|
||||
return j(handler, {"ok": True, "name": body["name"]})
|
||||
|
||||
|
||||
def _handle_skill_toggle(handler, body):
|
||||
"""Enable or disable a skill by name."""
|
||||
name = body.get("name")
|
||||
if not name:
|
||||
return bad(handler, "Missing field: name")
|
||||
enabled = body.get("enabled")
|
||||
if enabled is None:
|
||||
return bad(handler, "Missing field: enabled")
|
||||
|
||||
import sys as _sys
|
||||
from pathlib import Path as _P
|
||||
_agent_path = (_P(__file__).parent.parent / "hermes-agent").resolve()
|
||||
if str(_agent_path) not in _sys.path:
|
||||
_sys.path.insert(0, str(_agent_path))
|
||||
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
from api.config import load_config
|
||||
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
if enabled:
|
||||
disabled.discard(name)
|
||||
else:
|
||||
disabled.add(name)
|
||||
save_disabled_skills(config, disabled)
|
||||
return j(handler, {"ok": True, "name": name, "enabled": enabled})
|
||||
|
||||
|
||||
def _handle_memory_write(handler, body):
|
||||
try:
|
||||
require(body, "section", "content")
|
||||
|
||||
Reference in New Issue
Block a user