UI update changed
This commit is contained in:
@@ -219,6 +219,7 @@
|
||||
<h1>meshnet tracker</h1>
|
||||
<span class="meta" id="self-url"></span>
|
||||
<span class="meta" id="refreshed"></span>
|
||||
<button class="small" id="refresh-btn" type="button" onclick="refreshActiveTab(true)" style="margin-left:auto">refresh</button>
|
||||
</header>
|
||||
<nav class="dashboard-tabs" aria-label="Dashboard sections">
|
||||
<button id="tab-overview" class="active" onclick="switchDashboardTab('overview')">Overview</button>
|
||||
@@ -379,6 +380,75 @@ function modelServedCopiesFromMap(map, model) {
|
||||
return best;
|
||||
}
|
||||
|
||||
const panelSig = {};
|
||||
let pendingChatModelRefresh = false;
|
||||
|
||||
function stableSig(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderIfChanged(panelId, data, renderFn) {
|
||||
const sig = stableSig(data);
|
||||
if (panelSig[panelId] === sig) return false;
|
||||
panelSig[panelId] = sig;
|
||||
renderFn(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEditing(idOrEl) {
|
||||
const el = typeof idOrEl === "string" ? $(idOrEl) : idOrEl;
|
||||
if (!el) return false;
|
||||
const active = document.activeElement;
|
||||
return active === el || (active && el.contains(active));
|
||||
}
|
||||
|
||||
function refreshBlocked() {
|
||||
return selectionActive()
|
||||
|| chatBusy
|
||||
|| isEditing("account-nickname")
|
||||
|| isEditing("chat-prompt")
|
||||
|| isEditing("chat-model");
|
||||
}
|
||||
|
||||
function syncKeyedList(container, items, keyFn, sigFn, createFn, patchFn) {
|
||||
if (!container) return;
|
||||
const sig = stableSig(items.map(item => sigFn(item)));
|
||||
if (container.dataset.listSig === sig) return;
|
||||
container.dataset.listSig = sig;
|
||||
if (!items.length) {
|
||||
container.className = "chat-session-list empty-state";
|
||||
container.replaceChildren(document.createTextNode("No chats yet"));
|
||||
return;
|
||||
}
|
||||
container.className = "chat-session-list";
|
||||
const existing = new Map();
|
||||
for (const child of container.querySelectorAll("[data-session-id]")) {
|
||||
existing.set(child.dataset.sessionId, child);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
seen.add(key);
|
||||
let el = existing.get(key);
|
||||
if (!el) {
|
||||
el = createFn(item);
|
||||
} else {
|
||||
patchFn(el, item);
|
||||
}
|
||||
container.appendChild(el);
|
||||
}
|
||||
for (const [key, el] of existing) {
|
||||
if (!seen.has(key)) el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function chatModelsSignature() {
|
||||
return availableModels.map(model => ({
|
||||
id: model.id,
|
||||
label: chatModelOptionLabel(model, lastRouting),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
try {
|
||||
const headers = {};
|
||||
@@ -997,7 +1067,7 @@ function createNewChatSession() {
|
||||
clearChatPrompt();
|
||||
saveChatSessionsStore();
|
||||
renderChatSessionList();
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
renderChatAuthHint();
|
||||
const promptEl = $("chat-prompt");
|
||||
if (promptEl) promptEl.focus();
|
||||
@@ -1019,7 +1089,7 @@ function selectChatSession(sessionId) {
|
||||
}
|
||||
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
|
||||
renderChatSessionList();
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
renderChatAuthHint();
|
||||
}
|
||||
|
||||
@@ -1045,7 +1115,7 @@ function deleteChatSession(sessionId) {
|
||||
}
|
||||
saveChatSessionsStore();
|
||||
renderChatSessionList();
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
renderChatModels();
|
||||
}
|
||||
|
||||
@@ -1067,31 +1137,54 @@ function initChatSessions() {
|
||||
if (active.model) selectedChatModel = active.model;
|
||||
}
|
||||
renderChatSessionList();
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
}
|
||||
|
||||
function renderChatSessionList() {
|
||||
const list = $("chat-session-list");
|
||||
if (!list) return;
|
||||
if (!chatSessions.length) {
|
||||
list.className = "chat-session-list empty-state";
|
||||
list.innerHTML = "No chats yet";
|
||||
return;
|
||||
syncKeyedList(
|
||||
list,
|
||||
chatSessions,
|
||||
session => session.id,
|
||||
session => `${session.id}|${session.updatedAt || ""}|${chatSessionTitle(session)}|${session.id === activeChatSessionId}`,
|
||||
createSessionRowElement,
|
||||
patchSessionRowElement,
|
||||
);
|
||||
}
|
||||
|
||||
function createSessionRowElement(session) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "chat-session-item";
|
||||
el.dataset.sessionId = session.id;
|
||||
el.setAttribute("role", "button");
|
||||
el.tabIndex = 0;
|
||||
const title = document.createElement("div");
|
||||
title.className = "chat-session-title";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "chat-session-meta";
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "chat-session-delete";
|
||||
del.title = "Delete chat";
|
||||
del.dataset.deleteSession = session.id;
|
||||
del.textContent = "×";
|
||||
el.append(title, meta, del);
|
||||
patchSessionRowElement(el, session);
|
||||
return el;
|
||||
}
|
||||
|
||||
function patchSessionRowElement(el, session) {
|
||||
el.classList.toggle("active", session.id === activeChatSessionId);
|
||||
el.querySelector(".chat-session-title").textContent = chatSessionTitle(session);
|
||||
const when = formatSessionTime(session.updatedAt || session.createdAt);
|
||||
const meta = el.querySelector(".chat-session-meta");
|
||||
if (when) {
|
||||
meta.textContent = when;
|
||||
meta.hidden = false;
|
||||
} else {
|
||||
meta.textContent = "";
|
||||
meta.hidden = true;
|
||||
}
|
||||
list.className = "chat-session-list";
|
||||
list.innerHTML = chatSessions.map(session => {
|
||||
const active = session.id === activeChatSessionId ? " active" : "";
|
||||
const title = esc(chatSessionTitle(session));
|
||||
const when = esc(formatSessionTime(session.updatedAt || session.createdAt));
|
||||
const sessionId = esc(session.id);
|
||||
return `<div class="chat-session-item${active}" role="button" tabindex="0"` +
|
||||
` data-session-id="${sessionId}">` +
|
||||
`<div class="chat-session-title">${title}</div>` +
|
||||
(when ? `<div class="chat-session-meta">${when}</div>` : "") +
|
||||
`<button type="button" class="chat-session-delete" title="Delete chat"` +
|
||||
` data-delete-session="${sessionId}">×</button>` +
|
||||
`</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function bindChatSessionList() {
|
||||
@@ -1134,6 +1227,7 @@ function switchDashboardTab(name) {
|
||||
const promptEl = $("chat-prompt");
|
||||
if (promptEl) promptEl.focus();
|
||||
}
|
||||
refreshActiveTab(true);
|
||||
}
|
||||
|
||||
function updateSectionVisibility() {
|
||||
@@ -1149,22 +1243,33 @@ function renderChatStatus(text) {
|
||||
$("chat-status").textContent = text;
|
||||
}
|
||||
|
||||
function renderChatHistory() {
|
||||
function renderChatHistory(force) {
|
||||
const history = $("chat-history");
|
||||
if (!history) return;
|
||||
if (!force && chatHistory.some(msg => msg.streaming)) return;
|
||||
if (!chatHistory.length) {
|
||||
if (history.dataset.historySig === "empty") return;
|
||||
history.dataset.historySig = "empty";
|
||||
history.className = "chat-messages empty";
|
||||
history.innerHTML = '<div class="chat-messages-inner">Send a message to start this conversation.</div>';
|
||||
return;
|
||||
}
|
||||
const sig = stableSig(chatHistory.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
streaming: Boolean(msg.streaming),
|
||||
})));
|
||||
if (!force && history.dataset.historySig === sig) return;
|
||||
history.dataset.historySig = sig;
|
||||
history.className = "chat-messages";
|
||||
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
|
||||
const rows = chatHistory.map(msg => {
|
||||
const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
|
||||
const streaming = msg.streaming ? " streaming" : "";
|
||||
return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${esc(msg.content)}</div></div>`;
|
||||
}).join("");
|
||||
history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`;
|
||||
history.scrollTop = history.scrollHeight;
|
||||
if (nearBottom || force) history.scrollTop = history.scrollHeight;
|
||||
}
|
||||
|
||||
function findRoutingForModel(model, routing) {
|
||||
@@ -1205,9 +1310,17 @@ function chatModelOptionLabel(model, routing) {
|
||||
return `${base} · ${health} · ${speedText}${suffix}`;
|
||||
}
|
||||
|
||||
function renderChatModels() {
|
||||
function renderChatModels(force) {
|
||||
const select = $("chat-model");
|
||||
if (!select) return;
|
||||
const sig = stableSig(chatModelsSignature());
|
||||
if (!force && panelSig.chatModels === sig) return;
|
||||
if (!force && isEditing(select)) {
|
||||
pendingChatModelRefresh = true;
|
||||
return;
|
||||
}
|
||||
pendingChatModelRefresh = false;
|
||||
panelSig.chatModels = sig;
|
||||
const models = availableModels.slice();
|
||||
if (!models.length) {
|
||||
select.innerHTML = '<option value="">no models available</option>';
|
||||
@@ -1226,6 +1339,15 @@ function renderChatModels() {
|
||||
select.value = selectedChatModel;
|
||||
}
|
||||
|
||||
function bindChatModelSelect() {
|
||||
const select = $("chat-model");
|
||||
if (!select || select.dataset.bound) return;
|
||||
select.dataset.bound = "1";
|
||||
select.addEventListener("blur", () => {
|
||||
if (pendingChatModelRefresh) renderChatModels(true);
|
||||
});
|
||||
}
|
||||
|
||||
function selectChatModel(value) {
|
||||
selectedChatModel = value || "";
|
||||
localStorage.setItem("meshnet_chat_model", selectedChatModel);
|
||||
@@ -1325,7 +1447,8 @@ async function doRegister() {
|
||||
{ email: email || null, wallet: wallet || null, nickname: nickname || null, password });
|
||||
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
|
||||
setSession(r.data.session_token);
|
||||
await renderAccountPanel();
|
||||
await loadAccountSummary(true);
|
||||
await refreshActiveTab(true);
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
@@ -1334,7 +1457,8 @@ async function doLogin() {
|
||||
const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
|
||||
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
|
||||
setSession(r.data.session_token);
|
||||
await renderAccountPanel();
|
||||
await loadAccountSummary(true);
|
||||
await refreshActiveTab(true);
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
@@ -1345,19 +1469,19 @@ async function doLogout() {
|
||||
|
||||
async function createKey() {
|
||||
const r = await apiCall("/v1/account/keys", "POST", {});
|
||||
if (r.ok) await renderAccountPanel();
|
||||
if (r.ok) await loadAccountSummary(true);
|
||||
}
|
||||
|
||||
async function revokeKey(key) {
|
||||
if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
|
||||
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
|
||||
await renderAccountPanel();
|
||||
await loadAccountSummary(true);
|
||||
}
|
||||
|
||||
async function topupKey(key) {
|
||||
const r = await apiCall("/v1/account/topup", "POST", { api_key: key });
|
||||
if (!r.ok) alert(r.data.error || "top-up failed");
|
||||
await renderAccountPanel();
|
||||
await loadAccountSummary(true);
|
||||
}
|
||||
|
||||
const COPY_TOOLTIP_MS = 2000;
|
||||
@@ -1421,65 +1545,112 @@ function renderChatAuthHint() {
|
||||
}
|
||||
}
|
||||
|
||||
let accountTopupAmount = 0;
|
||||
|
||||
function buildAccountPanelShell() {
|
||||
$("account").innerHTML =
|
||||
`<div id="account-panel-root">` +
|
||||
`<div><b data-bind="who"></b> <span class="pill" data-bind="role"></span> ` +
|
||||
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
|
||||
`<div style="margin:6px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">` +
|
||||
`<label class="dim" style="margin:0">nickname</label>` +
|
||||
`<input id="account-nickname" type="text" placeholder="display name" style="min-width:180px">` +
|
||||
`<button class="small" type="button" onclick="saveNickname()">save</button></div>` +
|
||||
`<div style="margin:6px 0">balance: <b data-bind="balance"></b> USDT · requests: <b data-bind="requests"></b> · ` +
|
||||
`tokens: <b data-bind="tokens"></b> · spent: <b data-bind="spent"></b> USDT</div>` +
|
||||
`<div style="margin:6px 0"><b class="dim">API keys</b> ` +
|
||||
`<button class="small" onclick="createKey()">+ new key</button></div>` +
|
||||
`<div id="account-keys"></div></div>`;
|
||||
}
|
||||
|
||||
function renderAccountKeys(api_keys, balances, topup_amount) {
|
||||
const el = $("account-keys");
|
||||
if (!el) return;
|
||||
if (!api_keys.length) {
|
||||
el.innerHTML = '<div class="empty">no active keys</div>';
|
||||
return;
|
||||
}
|
||||
let html = "";
|
||||
for (const key of api_keys) {
|
||||
html += `<div class="keybox">` +
|
||||
`<span class="key-text" data-key="${esc(key)}" onclick="selectApiKeyText(this)" ondblclick="copyApiKeyFromTextEl(this)">${esc(key)}</span>` +
|
||||
`<span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
`<button class="small" type="button" onclick="copyApiKeyFromButton(this)">copy</button>` +
|
||||
(topup_amount > 0
|
||||
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
|
||||
: "") +
|
||||
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount) {
|
||||
const root = $("account-panel-root");
|
||||
if (!root) return false;
|
||||
accountTopupAmount = topup_amount || 0;
|
||||
root.querySelector('[data-bind="who"]').textContent = accountDisplayName(account);
|
||||
root.querySelector('[data-bind="role"]').textContent = account.role;
|
||||
const bal = root.querySelector('[data-bind="balance"]');
|
||||
bal.textContent = usdt(total_balance);
|
||||
bal.className = total_balance > 0 ? "ok" : "bad";
|
||||
root.querySelector('[data-bind="requests"]').textContent = String(usage.requests);
|
||||
root.querySelector('[data-bind="tokens"]').textContent = String(usage.total_tokens);
|
||||
root.querySelector('[data-bind="spent"]').textContent = usdt(usage.total_cost);
|
||||
const nick = $("account-nickname");
|
||||
if (nick && !isEditing(nick)) nick.value = account.nickname || "";
|
||||
renderIfChanged("account-keys", { api_keys, balances, topup_amount }, data => {
|
||||
renderAccountKeys(data.api_keys, data.balances, data.topup_amount);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveNickname() {
|
||||
const nickname = $("account-nickname").value.trim();
|
||||
const r = await apiCall("/v1/account/profile", "POST", { nickname: nickname || null });
|
||||
if (!r.ok) alert(r.data.error || "nickname update failed");
|
||||
else await renderAccountPanel();
|
||||
else await loadAccountSummary(true);
|
||||
}
|
||||
|
||||
async function renderAccountPanel() {
|
||||
function applyAccountSummary(data, force) {
|
||||
const { account, api_keys, balances, total_balance, usage, topup_amount } = data;
|
||||
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
|
||||
if (force || !patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount)) {
|
||||
buildAccountPanelShell();
|
||||
patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount);
|
||||
}
|
||||
renderChatAuthHint();
|
||||
setLoggedInMode(true);
|
||||
setAdminMode(account.role === "admin");
|
||||
}
|
||||
|
||||
async function loadAccountSummary(force) {
|
||||
const r = await apiCall("/v1/account");
|
||||
if (r.status === 404) { // accounts disabled on this tracker
|
||||
if (r.status === 404) {
|
||||
$("account-section").style.display = "none";
|
||||
accountApiKeys = [];
|
||||
accountUsageRecords = [];
|
||||
renderChatAuthHint();
|
||||
setLoggedInMode(false);
|
||||
setAdminMode(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!r.ok) { setSession(null); renderAuthForms(); return; }
|
||||
const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
|
||||
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
|
||||
accountUsageRecords = (usage && (usage.records || usage.recent)) || [];
|
||||
const who = accountDisplayName(account);
|
||||
let html =
|
||||
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
|
||||
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
|
||||
`<div style="margin:6px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">` +
|
||||
`<label class="dim" style="margin:0">nickname</label>` +
|
||||
`<input id="account-nickname" type="text" value="${esc(account.nickname || "")}" placeholder="display name" style="min-width:180px">` +
|
||||
`<button class="small" type="button" onclick="saveNickname()">save</button></div>` +
|
||||
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
|
||||
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
|
||||
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
|
||||
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
|
||||
'<button class="small" onclick="createKey()">+ new key</button></div>';
|
||||
if (api_keys.length) {
|
||||
for (const key of api_keys) {
|
||||
html += `<div class="keybox">` +
|
||||
`<span class="key-text" data-key="${esc(key)}" onclick="selectApiKeyText(this)" ondblclick="copyApiKeyFromTextEl(this)">${esc(key)}</span>` +
|
||||
`<span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
`<button class="small" type="button" onclick="copyApiKeyFromButton(this)">copy</button>` +
|
||||
(topup_amount > 0
|
||||
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
|
||||
: "") +
|
||||
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
|
||||
}
|
||||
} else {
|
||||
html += '<div class="empty">no active keys</div>';
|
||||
}
|
||||
$("account").innerHTML = html;
|
||||
renderUsageSummary(accountUsageRecords);
|
||||
renderNodeThroughput(lastStats);
|
||||
renderBillingUsage(accountUsageRecords);
|
||||
renderChatAuthHint();
|
||||
renderChatModels();
|
||||
renderChatHistory();
|
||||
setLoggedInMode(true);
|
||||
setAdminMode(account.role === "admin");
|
||||
if (account.role === "admin") await renderAdminPanel();
|
||||
if (!r.ok) { setSession(null); renderAuthForms(); return false; }
|
||||
applyAccountSummary(r.data, force);
|
||||
if (dashboardTab === "chat" && !refreshBlocked()) renderChatModels(force);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadAccountUsage() {
|
||||
const r = await apiCall("/v1/account/usage");
|
||||
if (!r.ok) return;
|
||||
accountUsageRecords = (r.data.records || r.data.recent) || [];
|
||||
renderIfChanged("usage-summary", accountUsageRecords, () => renderUsageSummary(accountUsageRecords));
|
||||
renderIfChanged("billing-usage", accountUsageRecords, () => renderBillingUsage(accountUsageRecords));
|
||||
}
|
||||
|
||||
/** @deprecated use loadAccountSummary */
|
||||
async function renderAccountPanel(force) {
|
||||
return loadAccountSummary(force);
|
||||
}
|
||||
|
||||
let chatAbortController = null;
|
||||
@@ -1501,7 +1672,7 @@ function updateStreamingChatBubble(content) {
|
||||
if (!history) return;
|
||||
const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming");
|
||||
const bubble = bubbles[bubbles.length - 1];
|
||||
if (!bubble) { renderChatHistory(); return; }
|
||||
if (!bubble) { renderChatHistory(true); return; }
|
||||
bubble.textContent = content;
|
||||
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
|
||||
if (nearBottom) history.scrollTop = history.scrollHeight;
|
||||
@@ -1511,7 +1682,7 @@ function finalizeAssistantMessage(msg) {
|
||||
delete msg.streaming;
|
||||
if (!chatHistory.includes(msg)) chatHistory.push(msg);
|
||||
if (!msg.content) msg.content = "(empty response)";
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
persistActiveChatSession();
|
||||
}
|
||||
|
||||
@@ -1519,7 +1690,7 @@ function removeAssistantMessage(msg) {
|
||||
const index = chatHistory.indexOf(msg);
|
||||
if (index >= 0) {
|
||||
chatHistory.splice(index, 1);
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1550,7 +1721,7 @@ async function sendChat() {
|
||||
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
|
||||
const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
|
||||
chatHistory.push(assistantMessage);
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
persistActiveChatSession();
|
||||
renderChatStatus("sending request…");
|
||||
let tokens = 0;
|
||||
@@ -1633,7 +1804,7 @@ async function sendChat() {
|
||||
removeAssistantMessage(assistantMessage);
|
||||
const error = (err && err.message) || "request failed";
|
||||
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
|
||||
renderChatHistory();
|
||||
renderChatHistory(true);
|
||||
persistActiveChatSession();
|
||||
renderChatStatus(error);
|
||||
}
|
||||
@@ -1661,29 +1832,13 @@ function bindChatPromptShortcuts() {
|
||||
});
|
||||
}
|
||||
|
||||
async function renderAdminPanel() {
|
||||
const r = await apiCall("/v1/admin/accounts");
|
||||
if (!r.ok) { setAdminMode(false); return; }
|
||||
const rows = (r.data.accounts || []).map(a => {
|
||||
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
|
||||
return [
|
||||
esc(short(accountDisplayName(a), 24)),
|
||||
`<span class="pill">${esc(a.role)}</span>`,
|
||||
String((a.api_keys || []).length),
|
||||
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
|
||||
new Date(a.created_ts * 1000).toLocaleDateString(),
|
||||
];
|
||||
});
|
||||
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
|
||||
}
|
||||
|
||||
async function cancelProxyRequest(requestId) {
|
||||
const r = await apiCall(
|
||||
`/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`,
|
||||
"POST",
|
||||
{},
|
||||
);
|
||||
if (r.ok) refresh();
|
||||
if (r.ok) await refreshCallWall();
|
||||
}
|
||||
|
||||
$("call-wall").addEventListener("click", (event) => {
|
||||
@@ -1694,25 +1849,8 @@ $("call-wall").addEventListener("click", (event) => {
|
||||
if (requestId) cancelProxyRequest(requestId);
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
$("self-url").textContent = location.host;
|
||||
const [raft, map, stats, models, consoleData, routing, adminData] = await Promise.all([
|
||||
fetchJson("/v1/raft/status"),
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/stats"),
|
||||
fetchJson("/v1/models"),
|
||||
fetchJson("/v1/console"),
|
||||
fetchJson("/v1/routing"),
|
||||
isAdmin ? Promise.all([
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/billing/settlements"),
|
||||
fetchJson("/v1/registry/wallets"),
|
||||
]) : Promise.resolve([null, null, null]),
|
||||
]);
|
||||
const [summary, settlements, wallets] = adminData;
|
||||
lastStats = stats;
|
||||
lastRouting = routing;
|
||||
lastNetworkMap = map;
|
||||
function applyAvailableModels(models, map) {
|
||||
lastNetworkMap = map || lastNetworkMap;
|
||||
availableModels = ((models && models.data) || []).map(model => {
|
||||
const entry = {
|
||||
id: model.id,
|
||||
@@ -1723,50 +1861,154 @@ async function refresh() {
|
||||
coverage: model.shard_coverage_percentage,
|
||||
servedCopies: model.served_model_copies,
|
||||
};
|
||||
entry.servedCopies = modelServedCopiesFromMap(map, entry);
|
||||
entry.servedCopies = modelServedCopiesFromMap(lastNetworkMap, entry);
|
||||
return entry;
|
||||
}).filter(model => model.id);
|
||||
renderHive(raft);
|
||||
renderNodes(map);
|
||||
renderBilling(summary);
|
||||
renderSettlements(settlements);
|
||||
renderFraud(wallets, summary);
|
||||
renderStats(stats);
|
||||
renderRouting(routing);
|
||||
renderCallWall(consoleData, stats, map);
|
||||
renderConsole(consoleData);
|
||||
renderNodeThroughput(stats);
|
||||
renderChatModels();
|
||||
renderChatHistory();
|
||||
}
|
||||
|
||||
function markRefreshed() {
|
||||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
const REFRESH_MS = 10000;
|
||||
async function fetchOverviewTab() {
|
||||
const fetches = [
|
||||
fetchJson("/v1/raft/status"),
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/stats"),
|
||||
fetchJson("/v1/routing"),
|
||||
fetchJson("/v1/console"),
|
||||
];
|
||||
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
|
||||
const results = await Promise.all(fetches);
|
||||
const [raft, map, stats, routing, consoleData, accountResp] = results;
|
||||
lastStats = stats;
|
||||
lastRouting = routing;
|
||||
lastNetworkMap = map;
|
||||
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
|
||||
renderIfChanged("hive", raft, renderHive);
|
||||
renderIfChanged("nodes", map, renderNodes);
|
||||
renderIfChanged("stats", stats, renderStats);
|
||||
renderIfChanged("routing", routing, renderRouting);
|
||||
renderIfChanged("call-wall", { consoleData, stats, map }, data => renderCallWall(data.consoleData, data.stats, data.map));
|
||||
}
|
||||
|
||||
async function fetchChatTab() {
|
||||
const fetches = [
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/models"),
|
||||
fetchJson("/v1/routing"),
|
||||
];
|
||||
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
|
||||
const [map, models, routing, accountResp] = await Promise.all(fetches);
|
||||
lastRouting = routing;
|
||||
applyAvailableModels(models, map);
|
||||
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
|
||||
if (!refreshBlocked()) {
|
||||
renderChatModels(true);
|
||||
} else {
|
||||
pendingChatModelRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBillingTab() {
|
||||
const [stats, , accountResp, adminData] = await Promise.all([
|
||||
fetchJson("/v1/stats"),
|
||||
loadAccountUsage(),
|
||||
isLoggedIn ? apiCall("/v1/account") : Promise.resolve(null),
|
||||
isAdmin ? Promise.all([
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/billing/settlements"),
|
||||
]) : Promise.resolve(null),
|
||||
]);
|
||||
lastStats = stats;
|
||||
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
|
||||
renderIfChanged("node-throughput", stats, renderNodeThroughput);
|
||||
if (adminData) {
|
||||
const [summary, settlements] = adminData;
|
||||
renderIfChanged("billing-summary", summary, renderBilling);
|
||||
renderIfChanged("settlements", settlements, renderSettlements);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAdminTab() {
|
||||
const fetches = [
|
||||
fetchJson("/v1/console"),
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/registry/wallets"),
|
||||
];
|
||||
if (isAdmin) fetches.push(apiCall("/v1/admin/accounts"));
|
||||
const results = await Promise.all(fetches);
|
||||
const [consoleData, summary, wallets, adminResp] = results;
|
||||
renderIfChanged("console", consoleData, renderConsole);
|
||||
renderIfChanged("billing-summary", summary, data => renderBilling(data));
|
||||
renderIfChanged("fraud", { wallets, summary }, data => renderFraud(data.wallets, data.summary));
|
||||
if (adminResp && adminResp.ok) {
|
||||
renderIfChanged("admin", adminResp.data.accounts || [], accounts => {
|
||||
const rows = accounts.map(a => {
|
||||
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
|
||||
return [
|
||||
esc(short(accountDisplayName(a), 24)),
|
||||
`<span class="pill">${esc(a.role)}</span>`,
|
||||
String((a.api_keys || []).length),
|
||||
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
|
||||
new Date(a.created_ts * 1000).toLocaleDateString(),
|
||||
];
|
||||
});
|
||||
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCallWall() {
|
||||
const [consoleData, map] = await Promise.all([
|
||||
fetchJson("/v1/console"),
|
||||
fetchJson("/v1/network/map"),
|
||||
]);
|
||||
if (map) lastNetworkMap = map;
|
||||
renderIfChanged("call-wall", { consoleData, stats: lastStats, map }, data =>
|
||||
renderCallWall(data.consoleData, data.stats, data.map));
|
||||
}
|
||||
|
||||
const TAB_FETCHERS = {
|
||||
overview: fetchOverviewTab,
|
||||
chat: fetchChatTab,
|
||||
billing: fetchBillingTab,
|
||||
admin: fetchAdminTab,
|
||||
};
|
||||
|
||||
async function refreshActiveTab(force) {
|
||||
$("self-url").textContent = location.host;
|
||||
if (refreshBlocked() && !force) return;
|
||||
const fetcher = TAB_FETCHERS[dashboardTab];
|
||||
if (fetcher) await fetcher();
|
||||
markRefreshed();
|
||||
}
|
||||
|
||||
const CALL_WALL_POLL_MS = 5000;
|
||||
|
||||
async function pollCallWallIfIdle() {
|
||||
if (dashboardTab !== "overview" || refreshBlocked()) return;
|
||||
await refreshCallWall();
|
||||
}
|
||||
|
||||
function selectionActive() {
|
||||
const sel = window.getSelection();
|
||||
return sel && !sel.isCollapsed && sel.toString().length > 0;
|
||||
}
|
||||
|
||||
async function refreshIfIdle() {
|
||||
if (selectionActive()) return;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
refresh();
|
||||
bindChatSessionList();
|
||||
bindChatModelSelect();
|
||||
initChatSessions();
|
||||
bindChatPromptShortcuts();
|
||||
renderAccountPanel();
|
||||
renderChatModels();
|
||||
renderChatHistory();
|
||||
renderChatAuthHint();
|
||||
setInterval(refreshIfIdle, REFRESH_MS);
|
||||
setInterval(() => {
|
||||
if (!sessionToken && !isLoggedIn) return;
|
||||
if (selectionActive()) return;
|
||||
renderAccountPanel();
|
||||
}, REFRESH_MS);
|
||||
(async () => {
|
||||
if (sessionToken) await loadAccountSummary(true);
|
||||
else renderAuthForms();
|
||||
await refreshActiveTab(true);
|
||||
renderChatHistory(true);
|
||||
renderChatAuthHint();
|
||||
if (dashboardTab === "chat") renderChatModels(true);
|
||||
})();
|
||||
setInterval(pollCallWallIfIdle, CALL_WALL_POLL_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user