This commit is contained in:
Dobromir Popov
2026-07-08 20:17:06 +02:00
4 changed files with 134 additions and 18 deletions

View File

@@ -127,9 +127,9 @@
display:flex; align-items:center; gap:8px; color:var(--dim); margin:0; display:flex; align-items:center; gap:8px; color:var(--dim); margin:0;
} }
.chat-toolbar select { .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); 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-status { color:var(--dim); font-size:12px; margin-left:auto; }
.chat-messages { .chat-messages {
@@ -277,6 +277,7 @@ const esc = s => String(s).replace(/[&<>"]/g,
const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6); 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 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 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; }; const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; };
function modelAliasKey(value) { function modelAliasKey(value) {
@@ -831,6 +832,7 @@ let isLoggedIn = false;
let accountApiKeys = []; let accountApiKeys = [];
let accountUsageRecords = []; let accountUsageRecords = [];
let lastStats = null; let lastStats = null;
let lastRouting = null;
let availableModels = []; let availableModels = [];
let chatHistory = []; let chatHistory = [];
let chatBusy = false; let chatBusy = false;
@@ -956,11 +958,7 @@ function selectChatSession(sessionId) {
renderChatAuthHint(); renderChatAuthHint();
} }
function deleteChatSession(sessionId, event) { function deleteChatSession(sessionId) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (chatBusy) return; if (chatBusy) return;
const index = chatSessions.findIndex(item => item.id === sessionId); const index = chatSessions.findIndex(item => item.id === sessionId);
if (index < 0) return; if (index < 0) return;
@@ -1020,18 +1018,43 @@ function renderChatSessionList() {
const active = session.id === activeChatSessionId ? " active" : ""; const active = session.id === activeChatSessionId ? " active" : "";
const title = esc(chatSessionTitle(session)); const title = esc(chatSessionTitle(session));
const when = esc(formatSessionTime(session.updatedAt || session.createdAt)); const when = esc(formatSessionTime(session.updatedAt || session.createdAt));
const id = JSON.stringify(session.id); const sessionId = esc(session.id);
return `<div class="chat-session-item${active}" role="button" tabindex="0"` + return `<div class="chat-session-item${active}" role="button" tabindex="0"` +
` onclick="selectChatSession(${id})"` + ` data-session-id="${sessionId}">` +
` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();selectChatSession(${id});}">` +
`<div class="chat-session-title">${title}</div>` + `<div class="chat-session-title">${title}</div>` +
(when ? `<div class="chat-session-meta">${when}</div>` : "") + (when ? `<div class="chat-session-meta">${when}</div>` : "") +
`<button type="button" class="chat-session-delete" title="Delete chat"` + `<button type="button" class="chat-session-delete" title="Delete chat"` +
` onclick="deleteChatSession(${id}, event)">×</button>` + ` data-delete-session="${sessionId}">×</button>` +
`</div>`; `</div>`;
}).join(""); }).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) { function switchDashboardTab(name) {
if (name === "admin" && !isAdmin) name = "overview"; if (name === "admin" && !isAdmin) name = "overview";
if (name === "billing" && !isLoggedIn) name = "overview"; if (name === "billing" && !isLoggedIn) name = "overview";
@@ -1079,6 +1102,44 @@ function renderChatHistory() {
history.scrollTop = history.scrollHeight; 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() { function renderChatModels() {
const select = $("chat-model"); const select = $("chat-model");
if (!select) return; if (!select) return;
@@ -1094,11 +1155,8 @@ function renderChatModels() {
selectedChatModel = preferred.id; selectedChatModel = preferred.id;
localStorage.setItem("meshnet_chat_model", selectedChatModel); localStorage.setItem("meshnet_chat_model", selectedChatModel);
select.innerHTML = models.map(model => { select.innerHTML = models.map(model => {
const label = model.name && model.name !== model.id const label = chatModelOptionLabel(model, lastRouting);
? `${model.name} (${model.id})` return `<option value="${esc(model.id)}"${model.id === selectedChatModel ? " selected" : ""}>${esc(label)}</option>`;
: model.id;
const suffix = model.recommended ? " [recommended]" : "";
return `<option value="${esc(model.id)}"${model.id === selectedChatModel ? " selected" : ""}>${esc(label + suffix)}</option>`;
}).join(""); }).join("");
select.value = selectedChatModel; select.value = selectedChatModel;
} }
@@ -1493,6 +1551,7 @@ async function sendChat() {
renderChatStatus("stopped"); renderChatStatus("stopped");
} }
} else { } else {
console.error("chat stream failed", err);
removeAssistantMessage(assistantMessage); removeAssistantMessage(assistantMessage);
const error = (err && err.message) || "request failed"; const error = (err && err.message) || "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel }); chatHistory.push({ role: "error", content: error, model: selectedChatModel });
@@ -1574,12 +1633,15 @@ async function refresh() {
]); ]);
const [summary, settlements, wallets] = adminData; const [summary, settlements, wallets] = adminData;
lastStats = stats; lastStats = stats;
lastRouting = routing;
availableModels = ((models && models.data) || []).map(model => ({ availableModels = ((models && models.data) || []).map(model => ({
id: model.id, id: model.id,
name: model.name || model.id, name: model.name || model.id,
hf_repo: model.hf_repo, hf_repo: model.hf_repo,
recommended: Boolean(model.recommended), recommended: Boolean(model.recommended),
aliases: model.aliases || [], aliases: model.aliases || [],
coverage: model.shard_coverage_percentage,
servedCopies: model.served_model_copies,
})).filter(model => model.id); })).filter(model => model.id);
renderHive(raft); renderHive(raft);
renderNodes(map); renderNodes(map);
@@ -1609,6 +1671,7 @@ async function refreshIfIdle() {
} }
refresh(); refresh();
bindChatSessionList();
initChatSessions(); initChatSessions();
bindChatPromptShortcuts(); bindChatPromptShortcuts();
renderAccountPanel(); renderAccountPanel();

View File

@@ -2729,6 +2729,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
required_start, required_start,
required_end, required_end,
) )
served_copies = _served_model_copies(
model_nodes,
required_start,
required_end,
)
aliases = [name] aliases = [name]
hf_repo = preset.get("hf_repo") hf_repo = preset.get("hf_repo")
if hf_repo and hf_repo not in aliases: if hf_repo and hf_repo not in aliases:
@@ -2748,6 +2753,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"recommended": bool(preset.get("recommended", False)), "recommended": bool(preset.get("recommended", False)),
"deployment": _deployment_summary(alive, preset), "deployment": _deployment_summary(alive, preset),
"shard_coverage_percentage": coverage, "shard_coverage_percentage": coverage,
"served_model_copies": served_copies,
}) })
seen_ids.add(name) seen_ids.add(name)
if hf_repo: 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]] aliases = [model_id, *[name for name in short_names if name != model_id]]
required_start = 0 required_start = 0
required_end = max(node.num_layers for node in model_nodes) - 1 required_end = max(node.num_layers for node in model_nodes) - 1
coverage = _coverage_percentage(
model_nodes,
required_start,
required_end,
)
data.append({ data.append({
"id": model_id, "id": model_id,
"object": "model", "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, "hf_repo": model_id if any(node.hf_repo == model_id for node in model_nodes) else None,
"aliases": aliases, "aliases": aliases,
"metadata": _model_metadata_from_nodes(model_nodes), "metadata": _model_metadata_from_nodes(model_nodes),
"shard_coverage_percentage": _coverage_percentage( "shard_coverage_percentage": coverage,
"served_model_copies": _served_model_copies(
model_nodes, model_nodes,
required_start, required_start,
required_end, required_end,
@@ -3457,6 +3469,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self.end_headers() self.end_headers()
stream_usage: dict | None = None stream_usage: dict | None = None
observed_stream_tokens = 0 observed_stream_tokens = 0
@@ -3499,7 +3512,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
if usage is not None: if usage is not None:
stream_usage = usage stream_usage = usage
except (BrokenPipeError, ConnectionResetError): except (BrokenPipeError, ConnectionResetError):
pass client_gone = True
if self._finalize_proxy_cancel( if self._finalize_proxy_cancel(
proxy_ctx=proxy_ctx, proxy_ctx=proxy_ctx,
server=server, server=server,
@@ -3537,6 +3550,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
route_model=route_model, route_model=route_model,
status=200, status=200,
stream=True, stream=True,
client_disconnected=client_gone,
tokens=tokens, tokens=tokens,
elapsed_seconds=round(elapsed, 4), elapsed_seconds=round(elapsed, 4),
tokens_per_sec=round(tokens / elapsed, 4) if elapsed > 0 else 0.0, 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_response(int(first.get("status", 200)))
self.send_header("Content-Type", headers.get("Content-Type", "text/event-stream; charset=utf-8")) 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("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self.end_headers() self.end_headers()
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
stream_usage: dict | None = None stream_usage: dict | None = None
@@ -3811,6 +3826,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
route_model=route_model, route_model=route_model,
status=200, status=200,
stream=True, stream=True,
client_disconnected=client_gone,
relay=True, relay=True,
tokens=tokens, tokens=tokens,
elapsed_seconds=round(elapsed, 4), elapsed_seconds=round(elapsed, 4),

View File

@@ -49,6 +49,42 @@ def test_dashboard_chat_uses_streaming_fetch():
assert "stream: true" in html assert "stream: true" in html
assert ".body.getReader()" in html assert ".body.getReader()" in html
assert '=== "[DONE]"' 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(): def test_dashboard_served_by_follower():

View File

@@ -467,6 +467,7 @@ def test_tracker_logs_stream_progress_before_request_completes():
method="POST", method="POST",
) )
response = urllib.request.urlopen(req, timeout=3.0) response = urllib.request.urlopen(req, timeout=3.0)
assert response.headers["X-Accel-Buffering"] == "no"
first_line = response.readline() first_line = response.readline()
assert first_line.startswith(b"data:") assert first_line.startswith(b"data:")
assert chunk_sent.wait(timeout=1.0) assert chunk_sent.wait(timeout=1.0)