+
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();