Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- TASKS.md +1 -1
- app/feature_overrides.py +78 -0
- app/routes/admin.py +36 -0
- app/routes/rooms.py +4 -3
- app/settings.py +14 -5
- docs/TROUBLESHOOTING.md +2 -0
- static/dashboard.html +37 -0
- static/dashboard.js +123 -7
TASKS.md
CHANGED
|
@@ -23,7 +23,7 @@ Legend:
|
|
| 23 |
- [x] Landing + route split (`/` landing, `/login`, `/app`) and UI redirects updated.
|
| 24 |
- [x] Split `static/dashboard.html` into JS/CSS files (`static/dashboard.js`, `static/dashboard.css`).
|
| 25 |
- [x] Theme tokens shared across login + dashboard (single source of truth via `static/theme.css`).
|
| 26 |
-
- [~] Separate Settings vs Admin dashboard (
|
| 27 |
|
| 28 |
## P2 — Provider auth parity (Codex/Gemini/Claude)
|
| 29 |
- [x] Codex auth file generation from env/secrets (`~/.codex/.auth.json` and `~/.codex/auth.json`).
|
|
|
|
| 23 |
- [x] Landing + route split (`/` landing, `/login`, `/app`) and UI redirects updated.
|
| 24 |
- [x] Split `static/dashboard.html` into JS/CSS files (`static/dashboard.js`, `static/dashboard.css`).
|
| 25 |
- [x] Theme tokens shared across login + dashboard (single source of truth via `static/theme.css`).
|
| 26 |
+
- [~] Separate Settings vs Admin dashboard (deep links + admin feature overrides done; full dedicated pages pending).
|
| 27 |
|
| 28 |
## P2 — Provider auth parity (Codex/Gemini/Claude)
|
| 29 |
- [x] Codex auth file generation from env/secrets (`~/.codex/.auth.json` and `~/.codex/auth.json`).
|
app/feature_overrides.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from app.storage import global_data_dir
|
| 9 |
+
|
| 10 |
+
_LOCK = threading.Lock()
|
| 11 |
+
_CACHE_AT: float = 0.0
|
| 12 |
+
_CACHE: dict[str, bool] = {}
|
| 13 |
+
_TTL_SEC = 3.0
|
| 14 |
+
_PATH = global_data_dir() / "feature-overrides.json"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_feature_overrides() -> dict[str, bool]:
|
| 18 |
+
"""
|
| 19 |
+
Loads persisted feature overrides (admin-managed).
|
| 20 |
+
|
| 21 |
+
Shape: {"version": 1, "overrides": {"terminal": true, "mcp": false, ...}}
|
| 22 |
+
"""
|
| 23 |
+
global _CACHE_AT, _CACHE
|
| 24 |
+
now = time.monotonic()
|
| 25 |
+
if now - _CACHE_AT < _TTL_SEC:
|
| 26 |
+
return dict(_CACHE)
|
| 27 |
+
|
| 28 |
+
with _LOCK:
|
| 29 |
+
now = time.monotonic()
|
| 30 |
+
if now - _CACHE_AT < _TTL_SEC:
|
| 31 |
+
return dict(_CACHE)
|
| 32 |
+
path = _PATH
|
| 33 |
+
try:
|
| 34 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 35 |
+
except FileNotFoundError:
|
| 36 |
+
_CACHE = {}
|
| 37 |
+
_CACHE_AT = now
|
| 38 |
+
return {}
|
| 39 |
+
except Exception:
|
| 40 |
+
_CACHE_AT = now
|
| 41 |
+
return dict(_CACHE)
|
| 42 |
+
|
| 43 |
+
overrides = data.get("overrides") if isinstance(data, dict) else None
|
| 44 |
+
out: dict[str, bool] = {}
|
| 45 |
+
if isinstance(overrides, dict):
|
| 46 |
+
for k, v in overrides.items():
|
| 47 |
+
key = str(k).strip()
|
| 48 |
+
if not key:
|
| 49 |
+
continue
|
| 50 |
+
if isinstance(v, bool):
|
| 51 |
+
out[key] = v
|
| 52 |
+
_CACHE = out
|
| 53 |
+
_CACHE_AT = now
|
| 54 |
+
return dict(_CACHE)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def save_feature_overrides(overrides: dict[str, Any]) -> dict[str, bool]:
|
| 58 |
+
"""
|
| 59 |
+
Persists a partial or full overrides dict (values must be bool).
|
| 60 |
+
"""
|
| 61 |
+
global _CACHE_AT, _CACHE
|
| 62 |
+
out: dict[str, bool] = {}
|
| 63 |
+
for k, v in (overrides or {}).items():
|
| 64 |
+
key = str(k).strip()
|
| 65 |
+
if not key:
|
| 66 |
+
continue
|
| 67 |
+
if isinstance(v, bool):
|
| 68 |
+
out[key] = v
|
| 69 |
+
|
| 70 |
+
payload = {"version": 1, "overrides": out}
|
| 71 |
+
path = _PATH
|
| 72 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 74 |
+
|
| 75 |
+
with _LOCK:
|
| 76 |
+
_CACHE = dict(out)
|
| 77 |
+
_CACHE_AT = time.monotonic()
|
| 78 |
+
return out
|
app/routes/admin.py
CHANGED
|
@@ -8,7 +8,9 @@ from fastapi import APIRouter, HTTPException, Request
|
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
from app.auth import require_user_from_request
|
|
|
|
| 11 |
from app.routes.user import _is_admin
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
|
@@ -74,3 +76,37 @@ async def put_mcp_templates(body: McpTemplates, http_request: Request):
|
|
| 74 |
return {"ok": True, "count": len(templates)}
|
| 75 |
except Exception as e:
|
| 76 |
raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
from app.auth import require_user_from_request
|
| 11 |
+
from app.feature_overrides import load_feature_overrides, save_feature_overrides
|
| 12 |
from app.routes.user import _is_admin
|
| 13 |
+
from app.settings import feature_enabled
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
|
|
|
|
| 76 |
return {"ok": True, "count": len(templates)}
|
| 77 |
except Exception as e:
|
| 78 |
raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class FeatureOverridesBody(BaseModel):
|
| 82 |
+
overrides: dict[str, bool]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get("/api/admin/features")
|
| 86 |
+
async def get_feature_overrides(http_request: Request):
|
| 87 |
+
user = await require_user_from_request(http_request)
|
| 88 |
+
_require_admin(user)
|
| 89 |
+
overrides = load_feature_overrides()
|
| 90 |
+
features = ["terminal", "codex", "mcp", "indexing", "rooms"]
|
| 91 |
+
return {
|
| 92 |
+
"ok": True,
|
| 93 |
+
"features": {
|
| 94 |
+
f: {"enabled": feature_enabled(f), "override": overrides.get(f)}
|
| 95 |
+
for f in features
|
| 96 |
+
},
|
| 97 |
+
"overrides": overrides,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.put("/api/admin/features")
|
| 102 |
+
async def put_feature_overrides(body: FeatureOverridesBody, http_request: Request):
|
| 103 |
+
user = await require_user_from_request(http_request)
|
| 104 |
+
_require_admin(user)
|
| 105 |
+
allowed = {"terminal", "codex", "mcp", "indexing", "rooms"}
|
| 106 |
+
overrides = {}
|
| 107 |
+
for k, v in (body.overrides or {}).items():
|
| 108 |
+
key = str(k).strip()
|
| 109 |
+
if key in allowed and isinstance(v, bool):
|
| 110 |
+
overrides[key] = v
|
| 111 |
+
saved = save_feature_overrides(overrides)
|
| 112 |
+
return {"ok": True, "overrides": saved}
|
app/routes/rooms.py
CHANGED
|
@@ -200,12 +200,14 @@ async def websocket_rooms(websocket: WebSocket):
|
|
| 200 |
text = _limit_text(str(msg.get("text") or ""))
|
| 201 |
if not text:
|
| 202 |
continue
|
|
|
|
|
|
|
|
|
|
| 203 |
chat_msg = {
|
| 204 |
"type": "chat.message",
|
| 205 |
-
"id":
|
| 206 |
"roomId": room_id,
|
| 207 |
"ts": _now_iso(),
|
| 208 |
-
"fromUserId": user_id,
|
| 209 |
"fromDeviceId": device_id,
|
| 210 |
"text": text,
|
| 211 |
}
|
|
@@ -244,4 +246,3 @@ async def websocket_rooms(websocket: WebSocket):
|
|
| 244 |
if not peers:
|
| 245 |
websocket.app.state.rooms_connections.pop(room_id, None)
|
| 246 |
await _broadcast(websocket.app, room_id, {"type": "presence.leave", "roomId": room_id, "deviceId": device_id})
|
| 247 |
-
|
|
|
|
| 200 |
text = _limit_text(str(msg.get("text") or ""))
|
| 201 |
if not text:
|
| 202 |
continue
|
| 203 |
+
client_id = str(msg.get("clientId") or "").strip()
|
| 204 |
+
if not client_id or len(client_id) > 120:
|
| 205 |
+
client_id = str(uuid.uuid4())
|
| 206 |
chat_msg = {
|
| 207 |
"type": "chat.message",
|
| 208 |
+
"id": client_id,
|
| 209 |
"roomId": room_id,
|
| 210 |
"ts": _now_iso(),
|
|
|
|
| 211 |
"fromDeviceId": device_id,
|
| 212 |
"text": text,
|
| 213 |
}
|
|
|
|
| 246 |
if not peers:
|
| 247 |
websocket.app.state.rooms_connections.pop(room_id, None)
|
| 248 |
await _broadcast(websocket.app, room_id, {"type": "presence.leave", "roomId": room_id, "deviceId": device_id})
|
|
|
app/settings.py
CHANGED
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def env_truthy(name: str, default: bool = False) -> bool:
|
| 7 |
raw = os.environ.get(name)
|
|
@@ -15,12 +17,14 @@ def feature_enabled(feature: str) -> bool:
|
|
| 15 |
Safety: when Supabase isn't configured, disable dangerous features by default.
|
| 16 |
"""
|
| 17 |
has_supabase = bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_KEY"))
|
|
|
|
|
|
|
| 18 |
defaults = {
|
| 19 |
-
"terminal":
|
| 20 |
-
"codex":
|
| 21 |
-
"mcp":
|
| 22 |
"indexing": False,
|
| 23 |
-
"rooms":
|
| 24 |
}
|
| 25 |
env_map = {
|
| 26 |
"terminal": "ENABLE_TERMINAL",
|
|
@@ -31,4 +35,9 @@ def feature_enabled(feature: str) -> bool:
|
|
| 31 |
}
|
| 32 |
if feature not in env_map:
|
| 33 |
return False
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
|
| 5 |
+
from app.feature_overrides import load_feature_overrides
|
| 6 |
+
|
| 7 |
|
| 8 |
def env_truthy(name: str, default: bool = False) -> bool:
|
| 9 |
raw = os.environ.get(name)
|
|
|
|
| 17 |
Safety: when Supabase isn't configured, disable dangerous features by default.
|
| 18 |
"""
|
| 19 |
has_supabase = bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_KEY"))
|
| 20 |
+
if not has_supabase:
|
| 21 |
+
return False
|
| 22 |
defaults = {
|
| 23 |
+
"terminal": True,
|
| 24 |
+
"codex": True,
|
| 25 |
+
"mcp": True,
|
| 26 |
"indexing": False,
|
| 27 |
+
"rooms": True,
|
| 28 |
}
|
| 29 |
env_map = {
|
| 30 |
"terminal": "ENABLE_TERMINAL",
|
|
|
|
| 35 |
}
|
| 36 |
if feature not in env_map:
|
| 37 |
return False
|
| 38 |
+
|
| 39 |
+
env_enabled = env_truthy(env_map[feature], default=defaults[feature])
|
| 40 |
+
overrides = load_feature_overrides()
|
| 41 |
+
if feature in overrides:
|
| 42 |
+
return bool(overrides[feature])
|
| 43 |
+
return env_enabled
|
docs/TROUBLESHOOTING.md
CHANGED
|
@@ -69,6 +69,8 @@ HF Spaces generally supports PTYs, but custom runtimes may not.
|
|
| 69 |
|
| 70 |
Set `ENABLE_ROOMS=1` in your environment and restart the container.
|
| 71 |
|
|
|
|
|
|
|
| 72 |
## P2P isn’t connecting in Rooms
|
| 73 |
|
| 74 |
The Rooms view uses WebRTC DataChannels (optional, behind “Prefer P2P”). Some networks block UDP/WebRTC.
|
|
|
|
| 69 |
|
| 70 |
Set `ENABLE_ROOMS=1` in your environment and restart the container.
|
| 71 |
|
| 72 |
+
Admins can also toggle feature overrides from Settings → Admin.
|
| 73 |
+
|
| 74 |
## P2P isn’t connecting in Rooms
|
| 75 |
|
| 76 |
The Rooms view uses WebRTC DataChannels (optional, behind “Prefer P2P”). Some networks block UDP/WebRTC.
|
static/dashboard.html
CHANGED
|
@@ -433,6 +433,43 @@
|
|
| 433 |
<div class="text-xs text-gray-400 mb-2">Read-only admin status and server feature flags.</div>
|
| 434 |
<div id="admin-status" class="text-sm text-gray-200">Loading...</div>
|
| 435 |
<div id="admin-features" class="mt-2 grid grid-cols-2 gap-2 text-xs text-gray-300"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
<div class="mt-3">
|
| 437 |
<div class="text-xs font-semibold text-gray-400 mb-1 uppercase">MCP Templates (server)</div>
|
| 438 |
<textarea id="admin-mcp-templates" rows="6"
|
|
|
|
| 433 |
<div class="text-xs text-gray-400 mb-2">Read-only admin status and server feature flags.</div>
|
| 434 |
<div id="admin-status" class="text-sm text-gray-200">Loading...</div>
|
| 435 |
<div id="admin-features" class="mt-2 grid grid-cols-2 gap-2 text-xs text-gray-300"></div>
|
| 436 |
+
<div class="mt-3 bg-gray-900/30 border border-gray-700 rounded-lg p-3 space-y-2">
|
| 437 |
+
<div class="flex items-center justify-between gap-2">
|
| 438 |
+
<div class="text-xs font-semibold text-gray-300 uppercase">Feature Overrides</div>
|
| 439 |
+
<div class="flex gap-2">
|
| 440 |
+
<button onclick="loadAdminFeatureOverrides()"
|
| 441 |
+
class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Load</button>
|
| 442 |
+
<button onclick="saveAdminFeatureOverrides()"
|
| 443 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded text-xs">Save</button>
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
<div class="text-xs text-gray-400">
|
| 447 |
+
Overrides are persisted server-side (requires admin). Useful for temporarily disabling high-risk features without a redeploy.
|
| 448 |
+
</div>
|
| 449 |
+
<div class="grid grid-cols-2 gap-2 text-xs text-gray-200">
|
| 450 |
+
<label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
|
| 451 |
+
<input id="admin-override-terminal" type="checkbox" class="h-4 w-4 accent-blue-600">
|
| 452 |
+
Terminal
|
| 453 |
+
</label>
|
| 454 |
+
<label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
|
| 455 |
+
<input id="admin-override-codex" type="checkbox" class="h-4 w-4 accent-blue-600">
|
| 456 |
+
Codex
|
| 457 |
+
</label>
|
| 458 |
+
<label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
|
| 459 |
+
<input id="admin-override-mcp" type="checkbox" class="h-4 w-4 accent-blue-600">
|
| 460 |
+
MCP
|
| 461 |
+
</label>
|
| 462 |
+
<label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
|
| 463 |
+
<input id="admin-override-indexing" type="checkbox" class="h-4 w-4 accent-blue-600">
|
| 464 |
+
Indexing
|
| 465 |
+
</label>
|
| 466 |
+
<label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
|
| 467 |
+
<input id="admin-override-rooms" type="checkbox" class="h-4 w-4 accent-blue-600">
|
| 468 |
+
Rooms
|
| 469 |
+
</label>
|
| 470 |
+
</div>
|
| 471 |
+
<div id="admin-overrides-status" class="text-xs text-gray-500"></div>
|
| 472 |
+
</div>
|
| 473 |
<div class="mt-3">
|
| 474 |
<div class="text-xs font-semibold text-gray-400 mb-1 uppercase">MCP Templates (server)</div>
|
| 475 |
<textarea id="admin-mcp-templates" rows="6"
|
static/dashboard.js
CHANGED
|
@@ -197,7 +197,10 @@ let supabase;
|
|
| 197 |
const admin = document.getElementById('admin-panel');
|
| 198 |
if (admin) admin.scrollIntoView({ block: 'nearest' });
|
| 199 |
// Best-effort auto-load templates for admins.
|
| 200 |
-
setTimeout(() =>
|
|
|
|
|
|
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
function closeSettings() {
|
|
@@ -767,6 +770,72 @@ let supabase;
|
|
| 767 |
}
|
| 768 |
}
|
| 769 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
async function refreshCodexMcpServers() {
|
| 771 |
const out = document.getElementById('codex-mcp-list');
|
| 772 |
if (out) out.textContent = 'Loading...';
|
|
@@ -3094,6 +3163,10 @@ let supabase;
|
|
| 3094 |
let roomsActiveRoomId = null;
|
| 3095 |
let roomsPeers = [];
|
| 3096 |
let roomsP2P = new Map(); // deviceId -> { pc, dc, polite, makingOffer, ignoreOffer }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3097 |
|
| 3098 |
function getDeviceId() {
|
| 3099 |
const key = 'device_id_v1';
|
|
@@ -3142,9 +3215,10 @@ let supabase;
|
|
| 3142 |
box.appendChild(div);
|
| 3143 |
}
|
| 3144 |
|
| 3145 |
-
function appendRoomMessage(msg) {
|
| 3146 |
const box = document.getElementById('rooms-messages');
|
| 3147 |
if (!box) return;
|
|
|
|
| 3148 |
const fromDevice = String(msg?.fromDeviceId || '').trim();
|
| 3149 |
const isMe = fromDevice && fromDevice === getDeviceId();
|
| 3150 |
|
|
@@ -3156,15 +3230,22 @@ let supabase;
|
|
| 3156 |
? 'bg-blue-600/20 border-blue-500/30 text-gray-100'
|
| 3157 |
: 'bg-gray-800/60 border-gray-700 text-gray-100'
|
| 3158 |
}`;
|
|
|
|
| 3159 |
|
| 3160 |
const meta = document.createElement('div');
|
| 3161 |
meta.className = 'text-[11px] text-gray-400 mb-1 flex items-center justify-between gap-3';
|
| 3162 |
const who = document.createElement('span');
|
| 3163 |
who.textContent = isMe ? 'you' : (fromDevice ? fromDevice.slice(0, 8) : 'peer');
|
|
|
|
| 3164 |
const ts = document.createElement('span');
|
| 3165 |
ts.textContent = String(msg?.ts || '');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3166 |
meta.appendChild(who);
|
| 3167 |
-
meta.appendChild(
|
| 3168 |
|
| 3169 |
const text = document.createElement('div');
|
| 3170 |
text.textContent = String(msg?.text || '');
|
|
@@ -3174,6 +3255,8 @@ let supabase;
|
|
| 3174 |
wrap.appendChild(bubble);
|
| 3175 |
box.appendChild(wrap);
|
| 3176 |
box.scrollTop = box.scrollHeight;
|
|
|
|
|
|
|
| 3177 |
}
|
| 3178 |
|
| 3179 |
function renderRoomsList(rooms) {
|
|
@@ -3304,6 +3387,7 @@ let supabase;
|
|
| 3304 |
|
| 3305 |
async function setActiveRoom(roomId, name) {
|
| 3306 |
roomsActiveRoomId = String(roomId || '').trim();
|
|
|
|
| 3307 |
updateRoomsHeader(roomsActiveRoomId, name || 'Room');
|
| 3308 |
renderRoomsMessagesEmpty('Loading…');
|
| 3309 |
await loadRoomsList();
|
|
@@ -3333,11 +3417,30 @@ let supabase;
|
|
| 3333 |
}
|
| 3334 |
|
| 3335 |
function disconnectRoomsWs() {
|
|
|
|
| 3336 |
if (roomsWs) {
|
| 3337 |
try { roomsWs.close(); } catch { }
|
| 3338 |
roomsWs = null;
|
| 3339 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3340 |
setRoomsConnStatus('Disconnected');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3341 |
}
|
| 3342 |
|
| 3343 |
async function connectRoomsWs() {
|
|
@@ -3359,12 +3462,14 @@ let supabase;
|
|
| 3359 |
setRoomsConnStatus('Connecting…');
|
| 3360 |
|
| 3361 |
roomsWs.onopen = () => {
|
|
|
|
| 3362 |
setRoomsConnStatus('WS connected');
|
| 3363 |
maybeStartRoomP2p();
|
| 3364 |
};
|
| 3365 |
roomsWs.onclose = () => {
|
| 3366 |
setRoomsConnStatus('Disconnected');
|
| 3367 |
closeAllRoomP2p();
|
|
|
|
| 3368 |
};
|
| 3369 |
roomsWs.onmessage = (event) => {
|
| 3370 |
let msg;
|
|
@@ -3392,6 +3497,14 @@ let supabase;
|
|
| 3392 |
return;
|
| 3393 |
}
|
| 3394 |
if (t === 'chat.message') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3395 |
appendRoomMessage(msg);
|
| 3396 |
return;
|
| 3397 |
}
|
|
@@ -3418,20 +3531,23 @@ let supabase;
|
|
| 3418 |
const sent = sendRoomMessageViaP2p(text);
|
| 3419 |
if (sent) return;
|
| 3420 |
}
|
| 3421 |
-
|
|
|
|
|
|
|
| 3422 |
}
|
| 3423 |
|
| 3424 |
function sendRoomMessageViaP2p(text) {
|
|
|
|
| 3425 |
let any = false;
|
| 3426 |
for (const entry of roomsP2P.values()) {
|
| 3427 |
if (!entry?.dc || entry.dc.readyState !== 'open') continue;
|
| 3428 |
try {
|
| 3429 |
-
entry.dc.send(JSON.stringify({ type: 'chat.message', text, ts: new Date().toISOString() }));
|
| 3430 |
any = true;
|
| 3431 |
} catch { }
|
| 3432 |
}
|
| 3433 |
if (any) {
|
| 3434 |
-
appendRoomMessage({ text, ts: new Date().toISOString(), fromDeviceId: getDeviceId() });
|
| 3435 |
}
|
| 3436 |
return any;
|
| 3437 |
}
|
|
@@ -3514,7 +3630,7 @@ let supabase;
|
|
| 3514 |
let msg;
|
| 3515 |
try { msg = JSON.parse(e.data); } catch { return; }
|
| 3516 |
if (msg?.type === 'chat.message') {
|
| 3517 |
-
appendRoomMessage({ text: msg.text, ts: msg.ts, fromDeviceId: peerId });
|
| 3518 |
}
|
| 3519 |
};
|
| 3520 |
dc.onclose = () => {
|
|
|
|
| 197 |
const admin = document.getElementById('admin-panel');
|
| 198 |
if (admin) admin.scrollIntoView({ block: 'nearest' });
|
| 199 |
// Best-effort auto-load templates for admins.
|
| 200 |
+
setTimeout(() => {
|
| 201 |
+
loadAdminFeatureOverrides();
|
| 202 |
+
loadAdminMcpTemplates();
|
| 203 |
+
}, 0);
|
| 204 |
}
|
| 205 |
|
| 206 |
function closeSettings() {
|
|
|
|
| 770 |
}
|
| 771 |
}
|
| 772 |
|
| 773 |
+
function setAdminOverridesStatus(msg) {
|
| 774 |
+
const el = document.getElementById('admin-overrides-status');
|
| 775 |
+
if (el) el.textContent = msg || '';
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
function getAdminOverrideInputs() {
|
| 779 |
+
return {
|
| 780 |
+
terminal: document.getElementById('admin-override-terminal'),
|
| 781 |
+
codex: document.getElementById('admin-override-codex'),
|
| 782 |
+
mcp: document.getElementById('admin-override-mcp'),
|
| 783 |
+
indexing: document.getElementById('admin-override-indexing'),
|
| 784 |
+
rooms: document.getElementById('admin-override-rooms'),
|
| 785 |
+
};
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
async function loadAdminFeatureOverrides() {
|
| 789 |
+
const inputs = getAdminOverrideInputs();
|
| 790 |
+
if (!inputs.terminal) return;
|
| 791 |
+
try {
|
| 792 |
+
setAdminOverridesStatus('Loading…');
|
| 793 |
+
const res = await authFetch('/api/admin/features');
|
| 794 |
+
if (!res.ok) throw new Error(await res.text());
|
| 795 |
+
const data = await res.json();
|
| 796 |
+
const overrides = data?.overrides || {};
|
| 797 |
+
for (const key of Object.keys(inputs)) {
|
| 798 |
+
const el = inputs[key];
|
| 799 |
+
if (!el) continue;
|
| 800 |
+
if (Object.prototype.hasOwnProperty.call(overrides, key)) {
|
| 801 |
+
el.checked = !!overrides[key];
|
| 802 |
+
} else {
|
| 803 |
+
// If no override is set, fall back to effective state as the default UI value.
|
| 804 |
+
el.checked = !!data?.features?.[key]?.enabled;
|
| 805 |
+
}
|
| 806 |
+
}
|
| 807 |
+
setAdminOverridesStatus('Loaded.');
|
| 808 |
+
} catch (e) {
|
| 809 |
+
const msg = String(e?.message || e);
|
| 810 |
+
if (msg.includes('403')) return;
|
| 811 |
+
setAdminOverridesStatus(`Load failed: ${msg}`);
|
| 812 |
+
}
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
async function saveAdminFeatureOverrides() {
|
| 816 |
+
const inputs = getAdminOverrideInputs();
|
| 817 |
+
if (!inputs.terminal) return;
|
| 818 |
+
const overrides = {};
|
| 819 |
+
for (const key of Object.keys(inputs)) {
|
| 820 |
+
const el = inputs[key];
|
| 821 |
+
if (!el) continue;
|
| 822 |
+
overrides[key] = !!el.checked;
|
| 823 |
+
}
|
| 824 |
+
try {
|
| 825 |
+
setAdminOverridesStatus('Saving…');
|
| 826 |
+
const res = await authFetch('/api/admin/features', {
|
| 827 |
+
method: 'PUT',
|
| 828 |
+
headers: { 'Content-Type': 'application/json' },
|
| 829 |
+
body: JSON.stringify({ overrides }),
|
| 830 |
+
});
|
| 831 |
+
if (!res.ok) throw new Error(await res.text());
|
| 832 |
+
await res.json();
|
| 833 |
+
setAdminOverridesStatus('Saved.');
|
| 834 |
+
} catch (e) {
|
| 835 |
+
setAdminOverridesStatus(`Save failed: ${e?.message || e}`);
|
| 836 |
+
}
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
async function refreshCodexMcpServers() {
|
| 840 |
const out = document.getElementById('codex-mcp-list');
|
| 841 |
if (out) out.textContent = 'Loading...';
|
|
|
|
| 3163 |
let roomsActiveRoomId = null;
|
| 3164 |
let roomsPeers = [];
|
| 3165 |
let roomsP2P = new Map(); // deviceId -> { pc, dc, polite, makingOffer, ignoreOffer }
|
| 3166 |
+
let roomsMessageEls = new Map(); // messageId -> { el, statusEl }
|
| 3167 |
+
let roomsReconnectAttempts = 0;
|
| 3168 |
+
let roomsReconnectTimer = null;
|
| 3169 |
+
let roomsWsClosing = false;
|
| 3170 |
|
| 3171 |
function getDeviceId() {
|
| 3172 |
const key = 'device_id_v1';
|
|
|
|
| 3215 |
box.appendChild(div);
|
| 3216 |
}
|
| 3217 |
|
| 3218 |
+
function appendRoomMessage(msg, { status = '' } = {}) {
|
| 3219 |
const box = document.getElementById('rooms-messages');
|
| 3220 |
if (!box) return;
|
| 3221 |
+
const msgId = String(msg?.id || '').trim();
|
| 3222 |
const fromDevice = String(msg?.fromDeviceId || '').trim();
|
| 3223 |
const isMe = fromDevice && fromDevice === getDeviceId();
|
| 3224 |
|
|
|
|
| 3230 |
? 'bg-blue-600/20 border-blue-500/30 text-gray-100'
|
| 3231 |
: 'bg-gray-800/60 border-gray-700 text-gray-100'
|
| 3232 |
}`;
|
| 3233 |
+
if (msgId) bubble.dataset.messageId = msgId;
|
| 3234 |
|
| 3235 |
const meta = document.createElement('div');
|
| 3236 |
meta.className = 'text-[11px] text-gray-400 mb-1 flex items-center justify-between gap-3';
|
| 3237 |
const who = document.createElement('span');
|
| 3238 |
who.textContent = isMe ? 'you' : (fromDevice ? fromDevice.slice(0, 8) : 'peer');
|
| 3239 |
+
const right = document.createElement('span');
|
| 3240 |
const ts = document.createElement('span');
|
| 3241 |
ts.textContent = String(msg?.ts || '');
|
| 3242 |
+
const st = document.createElement('span');
|
| 3243 |
+
st.className = 'ml-2 text-gray-500';
|
| 3244 |
+
st.textContent = status ? String(status) : '';
|
| 3245 |
+
right.appendChild(ts);
|
| 3246 |
+
right.appendChild(st);
|
| 3247 |
meta.appendChild(who);
|
| 3248 |
+
meta.appendChild(right);
|
| 3249 |
|
| 3250 |
const text = document.createElement('div');
|
| 3251 |
text.textContent = String(msg?.text || '');
|
|
|
|
| 3255 |
wrap.appendChild(bubble);
|
| 3256 |
box.appendChild(wrap);
|
| 3257 |
box.scrollTop = box.scrollHeight;
|
| 3258 |
+
if (msgId) roomsMessageEls.set(msgId, { el: bubble, statusEl: st });
|
| 3259 |
+
return bubble;
|
| 3260 |
}
|
| 3261 |
|
| 3262 |
function renderRoomsList(rooms) {
|
|
|
|
| 3387 |
|
| 3388 |
async function setActiveRoom(roomId, name) {
|
| 3389 |
roomsActiveRoomId = String(roomId || '').trim();
|
| 3390 |
+
roomsMessageEls = new Map();
|
| 3391 |
updateRoomsHeader(roomsActiveRoomId, name || 'Room');
|
| 3392 |
renderRoomsMessagesEmpty('Loading…');
|
| 3393 |
await loadRoomsList();
|
|
|
|
| 3417 |
}
|
| 3418 |
|
| 3419 |
function disconnectRoomsWs() {
|
| 3420 |
+
roomsWsClosing = true;
|
| 3421 |
if (roomsWs) {
|
| 3422 |
try { roomsWs.close(); } catch { }
|
| 3423 |
roomsWs = null;
|
| 3424 |
}
|
| 3425 |
+
if (roomsReconnectTimer) {
|
| 3426 |
+
clearTimeout(roomsReconnectTimer);
|
| 3427 |
+
roomsReconnectTimer = null;
|
| 3428 |
+
}
|
| 3429 |
setRoomsConnStatus('Disconnected');
|
| 3430 |
+
setTimeout(() => { roomsWsClosing = false; }, 250);
|
| 3431 |
+
}
|
| 3432 |
+
|
| 3433 |
+
function scheduleRoomsReconnect() {
|
| 3434 |
+
if (roomsReconnectTimer) return;
|
| 3435 |
+
if (!roomsActiveRoomId) return;
|
| 3436 |
+
roomsReconnectAttempts += 1;
|
| 3437 |
+
if (roomsReconnectAttempts > 5) return;
|
| 3438 |
+
const delay = Math.min(8000, 600 * roomsReconnectAttempts);
|
| 3439 |
+
roomsReconnectTimer = setTimeout(() => {
|
| 3440 |
+
roomsReconnectTimer = null;
|
| 3441 |
+
connectRoomsWs().catch(() => { });
|
| 3442 |
+
}, delay);
|
| 3443 |
+
setRoomsConnStatus(`Reconnecting… (${roomsReconnectAttempts})`);
|
| 3444 |
}
|
| 3445 |
|
| 3446 |
async function connectRoomsWs() {
|
|
|
|
| 3462 |
setRoomsConnStatus('Connecting…');
|
| 3463 |
|
| 3464 |
roomsWs.onopen = () => {
|
| 3465 |
+
roomsReconnectAttempts = 0;
|
| 3466 |
setRoomsConnStatus('WS connected');
|
| 3467 |
maybeStartRoomP2p();
|
| 3468 |
};
|
| 3469 |
roomsWs.onclose = () => {
|
| 3470 |
setRoomsConnStatus('Disconnected');
|
| 3471 |
closeAllRoomP2p();
|
| 3472 |
+
if (!roomsWsClosing) scheduleRoomsReconnect();
|
| 3473 |
};
|
| 3474 |
roomsWs.onmessage = (event) => {
|
| 3475 |
let msg;
|
|
|
|
| 3497 |
return;
|
| 3498 |
}
|
| 3499 |
if (t === 'chat.message') {
|
| 3500 |
+
const msgId = String(msg?.id || '').trim();
|
| 3501 |
+
const fromDevice = String(msg?.fromDeviceId || '').trim();
|
| 3502 |
+
const isMe = fromDevice && fromDevice === getDeviceId();
|
| 3503 |
+
if (msgId && isMe && roomsMessageEls.has(msgId)) {
|
| 3504 |
+
const entry = roomsMessageEls.get(msgId);
|
| 3505 |
+
if (entry?.statusEl) entry.statusEl.textContent = 'delivered';
|
| 3506 |
+
return;
|
| 3507 |
+
}
|
| 3508 |
appendRoomMessage(msg);
|
| 3509 |
return;
|
| 3510 |
}
|
|
|
|
| 3531 |
const sent = sendRoomMessageViaP2p(text);
|
| 3532 |
if (sent) return;
|
| 3533 |
}
|
| 3534 |
+
const clientId = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())) + '-' + Math.random().toString(16).slice(2);
|
| 3535 |
+
appendRoomMessage({ id: clientId, text, ts: new Date().toISOString(), fromDeviceId: getDeviceId() }, { status: 'sending' });
|
| 3536 |
+
sendRoomsWs({ type: 'chat.send', text, clientId });
|
| 3537 |
}
|
| 3538 |
|
| 3539 |
function sendRoomMessageViaP2p(text) {
|
| 3540 |
+
const clientId = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())) + '-' + Math.random().toString(16).slice(2);
|
| 3541 |
let any = false;
|
| 3542 |
for (const entry of roomsP2P.values()) {
|
| 3543 |
if (!entry?.dc || entry.dc.readyState !== 'open') continue;
|
| 3544 |
try {
|
| 3545 |
+
entry.dc.send(JSON.stringify({ type: 'chat.message', id: clientId, text, ts: new Date().toISOString() }));
|
| 3546 |
any = true;
|
| 3547 |
} catch { }
|
| 3548 |
}
|
| 3549 |
if (any) {
|
| 3550 |
+
appendRoomMessage({ id: clientId, text, ts: new Date().toISOString(), fromDeviceId: getDeviceId() }, { status: 'p2p' });
|
| 3551 |
}
|
| 3552 |
return any;
|
| 3553 |
}
|
|
|
|
| 3630 |
let msg;
|
| 3631 |
try { msg = JSON.parse(e.data); } catch { return; }
|
| 3632 |
if (msg?.type === 'chat.message') {
|
| 3633 |
+
appendRoomMessage({ id: msg.id, text: msg.text, ts: msg.ts, fromDeviceId: peerId });
|
| 3634 |
}
|
| 3635 |
};
|
| 3636 |
dc.onclose = () => {
|