diff --git a/packages/tracker/meshnet_tracker/dashboard.html b/packages/tracker/meshnet_tracker/dashboard.html index 1c16306..2b1685e 100644 --- a/packages/tracker/meshnet_tracker/dashboard.html +++ b/packages/tracker/meshnet_tracker/dashboard.html @@ -59,21 +59,84 @@ .dashboard-tabs button.active { color:var(--accent); border-bottom-color:var(--accent); } .wide { grid-column:1 / -1; } section[hidden] { display:none !important; } - .chat-shell { display:grid; grid-template-columns:minmax(0, 1.35fr) minmax(320px, 0.65fr); gap:12px; } - .chat-pane { display:flex; flex-direction:column; gap:10px; min-width:0; } - .chat-panel { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:10px; } - .chat-controls { display:flex; gap:10px; align-items:end; flex-wrap:wrap; } - .chat-controls label { display:flex; flex-direction:column; gap:4px; color:var(--dim); } - .chat-controls select { min-width:220px; } - .chat-history { display:flex; flex-direction:column; gap:8px; min-height:220px; max-height:420px; overflow:auto; } - .chat-message { border:1px solid #21262d; border-radius:6px; padding:8px 10px; background:#10151d; } - .chat-role { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; } - .chat-role-user { color:var(--accent); } - .chat-role-assistant { color:var(--ok); } - .chat-role-error { color:var(--bad); } - .chat-compose { display:flex; flex-direction:column; gap:8px; } - .chat-compose textarea { min-height:112px; resize:vertical; width:100%; } - .chat-status { color:var(--dim); font-size:12px; } + .chat-app { + display:grid; grid-template-columns:260px minmax(0, 1fr); gap:0; + min-height:560px; border:1px solid var(--border); border-radius:8px; + overflow:hidden; background:var(--bg); + } + .chat-sidebar { + display:flex; flex-direction:column; min-height:0; + border-right:1px solid var(--border); background:var(--panel); + } + .chat-sidebar-header { + display:flex; flex-direction:column; gap:8px; padding:12px; + border-bottom:1px solid var(--border); + } + .chat-sidebar-header button { width:100%; text-align:left; } + .chat-session-list { + flex:1; overflow:auto; padding:8px; display:flex; flex-direction:column; gap:4px; + } + .chat-session-item { + position:relative; display:block; width:100%; text-align:left; + padding:8px 28px 8px 10px; border:1px solid transparent; border-radius:6px; + background:transparent; color:var(--fg); cursor:pointer; + } + .chat-session-item:hover { background:#10151d; border-color:#21262d; } + .chat-session-item.active { + background:#10151d; border-color:var(--accent); + } + .chat-session-title { + font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; + } + .chat-session-meta { + margin-top:2px; font-size:11px; color:var(--dim); + white-space:nowrap; overflow:hidden; text-overflow:ellipsis; + } + .chat-session-delete { + position:absolute; top:6px; right:4px; padding:0 5px; min-width:0; + border:0; background:transparent; color:var(--dim); line-height:1.2; + } + .chat-session-delete:hover { color:var(--bad); } + .chat-main { display:flex; flex-direction:column; min-height:560px; min-width:0; } + .chat-main-header { + display:flex; gap:12px; align-items:center; flex-wrap:wrap; + padding:10px 12px; border-bottom:1px solid var(--border); background:var(--panel); + } + .chat-main-header label { + display:flex; align-items:center; gap:8px; color:var(--dim); margin:0; + } + .chat-main-header select { min-width:220px; } + .chat-main-title { flex:1; min-width:120px; font-size:12px; color:var(--fg); } + .chat-status { color:var(--dim); font-size:12px; margin-left:auto; } + .chat-messages { + flex:1; overflow:auto; padding:16px; display:flex; flex-direction:column; gap:12px; + min-height:280px; + } + .chat-messages.empty { + justify-content:center; align-items:center; color:var(--dim); font-style:italic; + } + .chat-bubble-wrap { display:flex; flex-direction:column; gap:4px; max-width:88%; } + .chat-bubble-wrap.user { align-self:flex-end; align-items:flex-end; } + .chat-bubble-wrap.assistant, .chat-bubble-wrap.error { align-self:flex-start; align-items:flex-start; } + .chat-bubble-label { + font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim); + } + .chat-bubble { + padding:10px 12px; border-radius:12px; line-height:1.45; + white-space:pre-wrap; word-break:break-word; + } + .chat-bubble.user { background:#1f3a5f; border:1px solid #264a73; border-bottom-right-radius:4px; } + .chat-bubble.assistant { background:#10151d; border:1px solid #21262d; border-bottom-left-radius:4px; } + .chat-bubble.error { background:#1a1012; border:1px solid #5c2020; color:#ffb4b4; border-bottom-left-radius:4px; } + .chat-compose { + display:flex; gap:8px; align-items:flex-end; padding:12px; + border-top:1px solid var(--border); background:var(--panel); + } + .chat-compose textarea { + flex:1; min-height:44px; max-height:160px; resize:none; width:auto; + } + .chat-compose button { align-self:flex-end; min-width:72px; } + .chat-hint { padding:0 12px 10px; color:var(--dim); font-size:11px; } .console { background:var(--bg); border:1px solid var(--border); border-radius:6px; min-height:160px; max-height:280px; overflow:auto; padding:7px 9px; @@ -112,27 +175,28 @@

Model usage (RPM)

loading…

Call wall

loading...
-

Chat / inference

-
-
-
+

Chat

+
+ +
+
- -
-
- -
- -
-
-
-
-
+
New chat
select a model to start
-
no messages yet
+
Send a message to start this conversation.
+
+ + +
+
Enter to send · Shift+Enter for a new line · Sessions are saved in this browser
@@ -604,7 +668,198 @@ let lastStats = null; let availableModels = []; let chatHistory = []; let chatBusy = false; +let chatSessions = []; +let activeChatSessionId = ""; let selectedChatModel = localStorage.getItem("meshnet_chat_model") || ""; +const CHAT_SESSIONS_KEY = "meshnet_chat_sessions_v1"; +const CHAT_ACTIVE_SESSION_KEY = "meshnet_chat_active_session_v1"; +const CHAT_SESSIONS_LIMIT = 50; + +function newChatSessionId() { + if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); + return "chat-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); +} + +function loadChatSessionsStore() { + try { + const raw = localStorage.getItem(CHAT_SESSIONS_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveChatSessionsStore() { + localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(chatSessions)); + if (activeChatSessionId) { + localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId); + } +} + +function chatSessionTitle(session) { + const firstUser = (session.messages || []).find(msg => msg.role === "user"); + if (!firstUser || !firstUser.content) return "New chat"; + const text = String(firstUser.content).trim().replace(/\s+/g, " "); + return text.length > 42 ? text.slice(0, 42) + "…" : text; +} + +function formatSessionTime(iso) { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const sameDay = date.toDateString() === now.toDateString(); + if (sameDay) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function getActiveChatSession() { + return chatSessions.find(session => session.id === activeChatSessionId) || null; +} + +function persistActiveChatSession() { + const session = getActiveChatSession(); + if (!session) return; + session.messages = chatHistory.slice(); + session.model = selectedChatModel || session.model || ""; + session.title = chatSessionTitle(session); + session.updatedAt = new Date().toISOString(); + chatSessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + if (chatSessions.length > CHAT_SESSIONS_LIMIT) { + chatSessions = chatSessions.slice(0, CHAT_SESSIONS_LIMIT); + if (!chatSessions.some(item => item.id === activeChatSessionId)) { + activeChatSessionId = chatSessions[0].id; + chatHistory = chatSessions[0].messages.slice(); + } + } + saveChatSessionsStore(); + renderChatSessionList(); + renderChatMainTitle(); +} + +function createNewChatSession() { + if (chatBusy) return; + const session = { + id: newChatSessionId(), + title: "New chat", + model: selectedChatModel || "", + messages: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + chatSessions.unshift(session); + activeChatSessionId = session.id; + chatHistory = []; + saveChatSessionsStore(); + renderChatSessionList(); + renderChatHistory(); + renderChatMainTitle(); + renderChatAuthHint(); + const promptEl = $("chat-prompt"); + if (promptEl) promptEl.focus(); +} + +function selectChatSession(sessionId) { + if (chatBusy || sessionId === activeChatSessionId) return; + const session = chatSessions.find(item => item.id === sessionId); + if (!session) return; + activeChatSessionId = session.id; + chatHistory = (session.messages || []).slice(); + if (session.model) { + selectedChatModel = session.model; + localStorage.setItem("meshnet_chat_model", selectedChatModel); + const select = $("chat-model"); + if (select) select.value = selectedChatModel; + } + localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId); + renderChatSessionList(); + renderChatHistory(); + renderChatMainTitle(); + renderChatAuthHint(); +} + +function deleteChatSession(sessionId, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (chatBusy) return; + const index = chatSessions.findIndex(item => item.id === sessionId); + if (index < 0) return; + chatSessions.splice(index, 1); + if (activeChatSessionId === sessionId) { + if (chatSessions.length) { + activeChatSessionId = chatSessions[0].id; + chatHistory = (chatSessions[0].messages || []).slice(); + if (chatSessions[0].model) { + selectedChatModel = chatSessions[0].model; + localStorage.setItem("meshnet_chat_model", selectedChatModel); + } + } else { + saveChatSessionsStore(); + createNewChatSession(); + return; + } + } + saveChatSessionsStore(); + renderChatSessionList(); + renderChatHistory(); + renderChatMainTitle(); + renderChatModels(); +} + +function initChatSessions() { + chatSessions = loadChatSessionsStore(); + activeChatSessionId = localStorage.getItem(CHAT_ACTIVE_SESSION_KEY) || ""; + const active = chatSessions.find(session => session.id === activeChatSessionId); + if (!active) { + if (chatSessions.length) { + activeChatSessionId = chatSessions[0].id; + chatHistory = (chatSessions[0].messages || []).slice(); + if (chatSessions[0].model) selectedChatModel = chatSessions[0].model; + } else { + createNewChatSession(); + return; + } + } else { + chatHistory = (active.messages || []).slice(); + if (active.model) selectedChatModel = active.model; + } + renderChatSessionList(); + renderChatMainTitle(); + renderChatHistory(); +} + +function renderChatMainTitle() { + const el = $("chat-main-title"); + if (!el) return; + const session = getActiveChatSession(); + el.textContent = session ? chatSessionTitle(session) : "New chat"; +} + +function renderChatSessionList() { + const list = $("chat-session-list"); + if (!list) return; + if (!chatSessions.length) { + list.classList.add("empty"); + list.innerHTML = "no chats yet"; + return; + } + list.classList.remove("empty"); + list.innerHTML = chatSessions.map(session => { + const active = session.id === activeChatSessionId ? " active" : ""; + const title = esc(chatSessionTitle(session)); + const model = esc(short(session.model || "no model", 18)); + const when = esc(formatSessionTime(session.updatedAt || session.createdAt)); + const id = JSON.stringify(session.id); + return ``; + }).join(""); +} function switchDashboardTab(name) { if (name === "admin" && !isAdmin) name = "overview"; @@ -632,17 +887,21 @@ function renderChatStatus(text) { function renderChatHistory() { const history = $("chat-history"); + if (!history) return; if (!chatHistory.length) { history.classList.add("empty"); - history.innerHTML = "no messages yet"; + history.innerHTML = "Send a message to start this conversation."; return; } history.classList.remove("empty"); history.innerHTML = chatHistory.map(msg => { - const roleClass = msg.role === "user" ? "chat-role-user" : msg.role === "assistant" ? "chat-role-assistant" : "chat-role-error"; - const label = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error"; + const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error"; + const label = role === "user" ? "You" : role === "assistant" ? "Assistant" : "Error"; const meta = msg.model ? ` · ${esc(short(msg.model, 24))}` : ""; - return `
${label}${meta}
${esc(msg.content)}
`; + return `
` + + `
${label}${meta}
` + + `
${esc(msg.content)}
` + + `
`; }).join(""); history.scrollTop = history.scrollHeight; } @@ -674,12 +933,13 @@ function renderChatModels() { function selectChatModel(value) { selectedChatModel = value || ""; localStorage.setItem("meshnet_chat_model", selectedChatModel); -} - -function clearChatHistory() { - chatHistory = []; - renderChatHistory(); - renderChatStatus("history cleared"); + const session = getActiveChatSession(); + if (session) { + session.model = selectedChatModel; + session.updatedAt = new Date().toISOString(); + saveChatSessionsStore(); + renderChatSessionList(); + } } function chatAuthToken() { @@ -937,6 +1197,7 @@ async function sendChat() { promptEl.value = ""; chatHistory.push({ role: "user", content: prompt, model: selectedChatModel }); renderChatHistory(); + persistActiveChatSession(); renderChatStatus("sending request…"); const r = await apiCall("/v1/chat/completions", "POST", body, bearerToken); chatBusy = false; @@ -947,6 +1208,7 @@ async function sendChat() { : "request failed"; chatHistory.push({ role: "error", content: error, model: selectedChatModel }); renderChatHistory(); + persistActiveChatSession(); renderChatStatus(error); promptEl.focus(); return; @@ -959,12 +1221,29 @@ async function sendChat() { model: selectedChatModel, }); renderChatHistory(); + persistActiveChatSession(); renderChatStatus(usage ? `done: ${usage.total_tokens ?? "?"} tokens` : "done"); promptEl.focus(); } +function bindChatPromptShortcuts() { + const promptEl = $("chat-prompt"); + if (!promptEl || promptEl.dataset.bound === "1") return; + promptEl.dataset.bound = "1"; + promptEl.addEventListener("keydown", event => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + sendChat(); + } + }); + promptEl.addEventListener("input", () => { + promptEl.style.height = "auto"; + promptEl.style.height = Math.min(promptEl.scrollHeight, 160) + "px"; + }); +} + async function renderAdminPanel() { const r = await apiCall("/v1/admin/accounts"); if (!r.ok) { setAdminMode(false); return; } @@ -1034,6 +1313,8 @@ async function refresh() { $("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString(); } refresh(); +initChatSessions(); +bindChatPromptShortcuts(); renderAccountPanel(); renderChatModels(); renderChatHistory();