From 9008048c702572ba375c13a502c18fcafe086af4 Mon Sep 17 00:00:00 2001 From: Abdussamet Kocak Date: Tue, 23 Dec 2025 09:01:40 +0100 Subject: [PATCH] feat(chat): History and restore support --- chat.html | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 245 insertions(+), 17 deletions(-) diff --git a/chat.html b/chat.html index 67bbbf0..0f8cf04 100644 --- a/chat.html +++ b/chat.html @@ -98,8 +98,7 @@ } .system-message pre, - .system-message code, - .system-message pre { + .system-message code:not(pre code) { font-family: menlo, consolas, monospace; background: var(--bg-code); border-radius: 0.4rem; @@ -195,10 +194,10 @@ } #model-selector { + user-select: none; position: absolute; - position-anchor: --submit; - bottom: anchor(top); - right: anchor(right); + top: 1rem; + left: 1rem; opacity: 0.3; padding: 0.5rem; } @@ -206,6 +205,64 @@ opacity: 1; } + /* History panel (mirrors model selector behavior) */ + #history-panel { + position: absolute; + user-select: none; + top: 1rem; + right: 1rem; + opacity: 0.3; + padding: 0.5rem; + } + #history-panel:is(:hover, [open]) { + opacity: 1; + } + + #history-panel summary { + font-weight: bold; + } + + .history-item { + cursor: pointer; + padding: 0.25rem 0.2rem; + border-radius: 0.2rem; + display: flex; + gap: 0.5rem; + align-items: center; + } + + .history-item:hover { + background: rgba(0, 0, 0, 0.05); + } + + /* History list classes */ + .history-list { + min-width: 220px; + max-width: 320px; + padding-top: 0.5rem; + } + + .history-none { + opacity: 0.7; + } + + .history-time { + font-weight: 700; + } + .history-time::after { + content: " - "; + } + + .history-excerpt { + /* margin-left: 6px; */ + /* display: inline-block; */ + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + } + #model-selector summary { font-weight: bold; } @@ -290,6 +347,14 @@ const $prompt = document.querySelector("#prompt"); const $submit = document.querySelector("#submit"); const $modelSelector = document.querySelector("#model-selector"); + const $historyPanel = (function createHistoryPanel() { + const details = document.createElement("details"); + details.id = "history-panel"; + details.innerHTML = `History`; + return details; + })(); + // insert history panel next to model selector + $modelSelector.parentNode.appendChild($historyPanel); // grow the prompt textarea as needed const minRows = +$prompt.rows; @@ -305,10 +370,14 @@ // populate model selector $modelSelector.append( ...models.map((model) => { - return Object.assign(document.createElement("label"), { - className: "model-opt__label", - innerHTML: `
${model.name}
`, - }); + const $label = document.createElement("label"); + $label.className = "model-opt__label"; + const $input = document.createElement("input"); + $input.type = "radio"; + $input.name = "model"; + $input.value = model.name; + $label.append($input, model.name); + return $label; }) ); $modelSelector.addEventListener("mouseenter", (e) => { @@ -349,6 +418,143 @@ } $prompt.focus(); + const STORAGE_KEY = "chat_conversations"; + + function todayKey(date = new Date()) { + return date.toISOString().slice(0, 10); // YYYY-MM-DD + } + + function loadConversations() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch (e) { + console.error("Failed to load conversations", e); + return {}; + } + } + + function saveConversations(obj) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); + } catch (e) { + console.error("Failed to save conversations", e); + } + } + + function clearOldConversations() { + const all = loadConversations(); + const today = todayKey(); + Object.keys(all).forEach((k) => { + if (k !== today) delete all[k]; + }); + saveConversations(all); + } + + function saveConversationNow(messages) { + // messages: array of {role, content} + const all = loadConversations(); + const today = todayKey(); + all[today] ||= []; + const ts = new Date(); + const entry = { time: ts.toISOString(), messages }; + all[today].unshift(entry); + // keep only last 5 + if (all[today].length > 5) all[today] = all[today].slice(0, 5); + saveConversations(all); + renderHistoryList(); + } + + function formatTime(iso) { + const d = new Date(iso); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + function renderHistoryList() { + // remove existing list + const today = todayKey(); + const all = loadConversations(); + const $list = document.createElement("div"); + $list.className = "history-list"; + + const items = (all[today] || []).slice(0, 5); + if (items.length === 0) { + const $none = document.createElement("div"); + $none.textContent = "No recent conversations today"; + $none.className = "history-none"; + $list.appendChild($none); + } else { + items.forEach((entry, idx) => { + const firstUser = entry.messages.find((m) => m.role === "user")?.content || ""; + const preview = firstUser.trim().replace(/\n/g, " ").slice(0, 30); + + const $line = document.createElement("div"); + $line.className = "history-item"; + $line.dataset.idx = idx; + + const $timeSpan = document.createElement("span"); + $timeSpan.className = "history-time"; + $timeSpan.textContent = formatTime(entry.time); + + const $previewSpan = document.createElement("span"); + $previewSpan.className = "history-excerpt"; + $previewSpan.innerText = preview; + + $line.appendChild($timeSpan); + $line.appendChild($previewSpan); + $line.addEventListener("click", () => { + restoreConversation(entry.messages); + $historyPanel.removeAttribute("open"); + }); + $list.appendChild($line); + }); + } + + // clear previous children and append + $historyPanel.querySelectorAll(".history-list").forEach((n) => n.remove()); + const wrapper = document.createElement("div"); + wrapper.className = "history-list"; + wrapper.appendChild($list); + $historyPanel.appendChild(wrapper); + } + + function escapeHtml(str) { + return str.replace(/[&<>"']/g, function (m) { + return { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[m]; + }); + } + + function restoreConversation(messagesArr) { + // Clear current messages UI and re-populate + messages.length = 0; // clear array + // push system prompt back if exists + messagesArr.forEach((m) => messages.push(m)); + + // re-render messages UI + $messages.innerHTML = ""; + messages + .filter((m) => m.role !== "system") + .forEach((m) => { + const el = createMessageElement(m.content, m.role === "user"); + if (!(m.role === "user")) { + el.innerHTML = renderMarkdown(m.content); + } + $messages.appendChild(el); + }); + scrollToBottom($messages); + } + + // initialize + clearOldConversations(); + renderHistoryList(); + async function handleSubmit() { const prompt = $prompt.value.trim(); if (!prompt) return; @@ -407,6 +613,14 @@ } } finally { md.finish(); + // Save conversation after assistant finishes responding + try { + // clone messages to avoid mutation issues + const clone = messages.map((m) => ({ role: m.role, content: m.content })); + saveConversationNow(clone); + } catch (e) { + console.error("Failed to save conversation", e); + } $submit.textContent = "Send"; $submit.classList.remove("busy"); abortController = null; @@ -416,9 +630,14 @@ function createMessageElement(content, isUser) { const $el = document.createElement("div"); $el.className = `message ${isUser ? "user-message" : "system-message"}`; - if (isUser) { - $el.innerText = content; - } + $el.innerText = content; + // if (isUser) { + // } else { + // // For system messages we create a pre element so streaming markdown can render into it + // const $pre = document.createElement("pre"); + // $pre.textContent = content; + // $el.appendChild($pre); + // } return $el; } @@ -618,6 +837,15 @@ } } + function renderMarkdown(markdown) { + const $container = document.createElement("div"); + const renderer = smd.default_renderer($container); + const parser = smd.parser(renderer); + smd.parser_write(parser, markdown); + smd.parser_end(parser); + return $container.innerHTML; + } + class StreamingMarkdown { constructor($el) { this.$el = $el; @@ -682,12 +910,12 @@ $btn.textContent = "Copy"; $btn.addEventListener("click", (e) => { try { - const textArea = document.createElement("textarea"); - textArea.value = $code.innerText; - document.body.appendChild(textArea); - textArea.select(); + const $textArea = document.createElement("textarea"); + $textArea.value = $code.innerText; + document.body.appendChild($textArea); + $textArea.select(); document.execCommand("copy"); - document.body.removeChild(textArea); + document.body.removeChild($textArea); $btn.textContent = "Copied!"; setTimeout(() => ($btn.textContent = "Copy"), 1200);