diff --git a/packages/tracker/meshnet_tracker/dashboard.html b/packages/tracker/meshnet_tracker/dashboard.html index bfa2d58..bf82ea9 100644 --- a/packages/tracker/meshnet_tracker/dashboard.html +++ b/packages/tracker/meshnet_tracker/dashboard.html @@ -127,9 +127,9 @@ display:flex; align-items:center; gap:8px; color:var(--dim); margin:0; } .chat-toolbar select { - min-width:220px; max-width:min(420px, 50vw); + min-width:280px; max-width:min(520px, 60vw); color:var(--fg); background:var(--chat-input-bg); border:1px solid var(--border); - border-radius:6px; padding:6px 8px; + border-radius:6px; padding:6px 8px; font-size:13px; } .chat-status { color:var(--dim); font-size:12px; margin-left:auto; } .chat-messages { @@ -277,6 +277,7 @@ const esc = s => String(s).replace(/[&<>"]/g, const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6); const tps = v => (v === null || v === undefined) ? "?" : (Math.round(v * 10) / 10).toFixed(1); const copies = v => (v === null || v === undefined) ? "?" : Number(v).toFixed(2); +const hp = v => (v === null || v === undefined) ? "?HP" : `${Number(v).toFixed(1)}HP`; const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; }; function modelAliasKey(value) { @@ -831,6 +832,7 @@ let isLoggedIn = false; let accountApiKeys = []; let accountUsageRecords = []; let lastStats = null; +let lastRouting = null; let availableModels = []; let chatHistory = []; let chatBusy = false; @@ -956,11 +958,7 @@ function selectChatSession(sessionId) { renderChatAuthHint(); } -function deleteChatSession(sessionId, event) { - if (event) { - event.preventDefault(); - event.stopPropagation(); - } +function deleteChatSession(sessionId) { if (chatBusy) return; const index = chatSessions.findIndex(item => item.id === sessionId); if (index < 0) return; @@ -1020,18 +1018,43 @@ function renderChatSessionList() { const active = session.id === activeChatSessionId ? " active" : ""; const title = esc(chatSessionTitle(session)); const when = esc(formatSessionTime(session.updatedAt || session.createdAt)); - const id = JSON.stringify(session.id); + const sessionId = esc(session.id); return `
` + + ` data-session-id="${sessionId}">` + `
${title}
` + (when ? `
${when}
` : "") + `` + + ` data-delete-session="${sessionId}">×` + `
`; }).join(""); } +function bindChatSessionList() { + const list = $("chat-session-list"); + if (!list || list.dataset.bound) return; + list.dataset.bound = "1"; + list.addEventListener("click", event => { + const deleteBtn = event.target.closest("[data-delete-session]"); + if (deleteBtn) { + event.preventDefault(); + event.stopPropagation(); + deleteChatSession(deleteBtn.getAttribute("data-delete-session")); + return; + } + const item = event.target.closest("[data-session-id]"); + if (item) selectChatSession(item.getAttribute("data-session-id")); + }); + list.addEventListener("keydown", event => { + if (event.key !== "Enter" && event.key !== " ") return; + const deleteBtn = event.target.closest("[data-delete-session]"); + if (deleteBtn) return; + const item = event.target.closest("[data-session-id]"); + if (!item) return; + event.preventDefault(); + selectChatSession(item.getAttribute("data-session-id")); + }); +} + function switchDashboardTab(name) { if (name === "admin" && !isAdmin) name = "overview"; if (name === "billing" && !isLoggedIn) name = "overview"; @@ -1079,6 +1102,44 @@ function renderChatHistory() { history.scrollTop = history.scrollHeight; } +function findRoutingForModel(model, routing) { + const models = (routing && routing.models) || {}; + const keys = [model.id, model.name, model.hf_repo, ...(model.aliases || [])].filter(Boolean); + for (const key of keys) { + if (models[key]) return models[key]; + const aliasKey = modelAliasKey(key); + for (const [routeKey, info] of Object.entries(models)) { + if (modelAliasKey(routeKey) === aliasKey) return info; + } + } + return null; +} + +function chatModelHealthHp(servedCopies) { + return hp(servedCopies); +} + +function chatModelTypicalTps(model, routing) { + const info = findRoutingForModel(model, routing); + const routes = (info && info.routes) || []; + if (!routes.length) return null; + const proven = routes.filter(r => r.status === "proven" && Number(r.tps) > 0); + if (proven.length) return Math.max(...proven.map(r => Number(r.tps))); + const priors = routes.map(r => Number(r.prior_tps ?? r.tps ?? 0)).filter(v => v > 0); + return priors.length ? Math.max(...priors) : null; +} + +function chatModelOptionLabel(model, routing) { + const base = model.name && model.name !== model.id + ? `${model.name} (${model.id})` + : model.id; + const health = chatModelHealthHp(model.servedCopies); + const speed = chatModelTypicalTps(model, routing); + const speedText = speed === null ? "?" : `${tps(speed)} tok/s`; + const suffix = model.recommended ? " · recommended" : ""; + return `${base} · ${health} · ${speedText}${suffix}`; +} + function renderChatModels() { const select = $("chat-model"); if (!select) return; @@ -1094,11 +1155,8 @@ function renderChatModels() { selectedChatModel = preferred.id; localStorage.setItem("meshnet_chat_model", selectedChatModel); select.innerHTML = models.map(model => { - const label = model.name && model.name !== model.id - ? `${model.name} (${model.id})` - : model.id; - const suffix = model.recommended ? " [recommended]" : ""; - return ``; + const label = chatModelOptionLabel(model, lastRouting); + return ``; }).join(""); select.value = selectedChatModel; } @@ -1493,6 +1551,7 @@ async function sendChat() { renderChatStatus("stopped"); } } else { + console.error("chat stream failed", err); removeAssistantMessage(assistantMessage); const error = (err && err.message) || "request failed"; chatHistory.push({ role: "error", content: error, model: selectedChatModel }); @@ -1574,12 +1633,15 @@ async function refresh() { ]); const [summary, settlements, wallets] = adminData; lastStats = stats; + lastRouting = routing; availableModels = ((models && models.data) || []).map(model => ({ id: model.id, name: model.name || model.id, hf_repo: model.hf_repo, recommended: Boolean(model.recommended), aliases: model.aliases || [], + coverage: model.shard_coverage_percentage, + servedCopies: model.served_model_copies, })).filter(model => model.id); renderHive(raft); renderNodes(map); @@ -1609,6 +1671,7 @@ async function refreshIfIdle() { } refresh(); +bindChatSessionList(); initChatSessions(); bindChatPromptShortcuts(); renderAccountPanel(); diff --git a/packages/tracker/meshnet_tracker/server.py b/packages/tracker/meshnet_tracker/server.py index 73114da..c433da0 100644 --- a/packages/tracker/meshnet_tracker/server.py +++ b/packages/tracker/meshnet_tracker/server.py @@ -2729,6 +2729,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): required_start, required_end, ) + served_copies = _served_model_copies( + model_nodes, + required_start, + required_end, + ) aliases = [name] hf_repo = preset.get("hf_repo") if hf_repo and hf_repo not in aliases: @@ -2748,6 +2753,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): "recommended": bool(preset.get("recommended", False)), "deployment": _deployment_summary(alive, preset), "shard_coverage_percentage": coverage, + "served_model_copies": served_copies, }) seen_ids.add(name) if hf_repo: @@ -2778,6 +2784,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): aliases = [model_id, *[name for name in short_names if name != model_id]] required_start = 0 required_end = max(node.num_layers for node in model_nodes) - 1 + coverage = _coverage_percentage( + model_nodes, + required_start, + required_end, + ) data.append({ "id": model_id, "object": "model", @@ -2787,7 +2798,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): "hf_repo": model_id if any(node.hf_repo == model_id for node in model_nodes) else None, "aliases": aliases, "metadata": _model_metadata_from_nodes(model_nodes), - "shard_coverage_percentage": _coverage_percentage( + "shard_coverage_percentage": coverage, + "served_model_copies": _served_model_copies( model_nodes, required_start, required_end, @@ -3457,6 +3469,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-cache") + self.send_header("X-Accel-Buffering", "no") self.end_headers() stream_usage: dict | None = None observed_stream_tokens = 0 @@ -3499,7 +3512,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): if usage is not None: stream_usage = usage except (BrokenPipeError, ConnectionResetError): - pass + client_gone = True if self._finalize_proxy_cancel( proxy_ctx=proxy_ctx, server=server, @@ -3537,6 +3550,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): route_model=route_model, status=200, stream=True, + client_disconnected=client_gone, tokens=tokens, elapsed_seconds=round(elapsed, 4), tokens_per_sec=round(tokens / elapsed, 4) if elapsed > 0 else 0.0, @@ -3738,6 +3752,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): self.send_response(int(first.get("status", 200))) self.send_header("Content-Type", headers.get("Content-Type", "text/event-stream; charset=utf-8")) self.send_header("Cache-Control", "no-cache") + self.send_header("X-Accel-Buffering", "no") self.end_headers() server: _TrackerHTTPServer = self.server # type: ignore[assignment] stream_usage: dict | None = None @@ -3811,6 +3826,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): route_model=route_model, status=200, stream=True, + client_disconnected=client_gone, relay=True, tokens=tokens, elapsed_seconds=round(elapsed, 4), diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index fea342f..dd6e650 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -49,6 +49,42 @@ def test_dashboard_chat_uses_streaming_fetch(): assert "stream: true" in html assert ".body.getReader()" in html assert '=== "[DONE]"' in html + assert 'console.error("chat stream failed", err)' in html + + +def test_dashboard_chat_model_selector_shows_health_and_speed(): + tracker = TrackerServer() + port = tracker.start() + try: + html = urllib.request.urlopen( + f"http://127.0.0.1:{port}/dashboard" + ).read().decode() + finally: + tracker.stop() + + assert "chatModelHealthHp" in html + assert "chatModelTypicalTps" in html + assert "chatModelOptionLabel" in html + assert "findRoutingForModel" in html + assert "tok/s" in html + assert "0.0HP" in html or "toFixed(1)}HP" in html + + +def test_dashboard_chat_sessions_use_delegated_handlers(): + tracker = TrackerServer() + port = tracker.start() + try: + html = urllib.request.urlopen( + f"http://127.0.0.1:{port}/dashboard" + ).read().decode() + finally: + tracker.stop() + + assert "bindChatSessionList" in html + assert "data-session-id=" in html + assert "data-delete-session=" in html + assert 'onclick="selectChatSession(' not in html + assert 'onclick="deleteChatSession(' not in html def test_dashboard_served_by_follower(): diff --git a/tests/test_tracker_routing.py b/tests/test_tracker_routing.py index f6115f0..b09a8f4 100644 --- a/tests/test_tracker_routing.py +++ b/tests/test_tracker_routing.py @@ -467,6 +467,7 @@ def test_tracker_logs_stream_progress_before_request_completes(): method="POST", ) response = urllib.request.urlopen(req, timeout=3.0) + assert response.headers["X-Accel-Buffering"] == "no" first_line = response.readline() assert first_line.startswith(b"data:") assert chunk_sent.wait(timeout=1.0)