From ce65755d9ca8caed450848a0cd63a252cdde45dc Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 17:39:52 +0000 Subject: [PATCH] feat(web): render Lyra's replies as Markdown (readable, not a wall of asterisks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her replies are full of **bold**, numbered lists and headings but rendered as raw monospace text, so the chat was a cluttered wall of literal markup. Add a small self-contained Markdown renderer (no deps): headings, ordered/unordered lists, bold/italic, inline + fenced code, links + autolinked URLs, with HTML escaping. Assistant messages now render to HTML; user/system stay literal text. Proportional font + spacing/list/code styling for assistant bubbles. (Renderer avoids literal backticks via String.fromCharCode(96) — a triple-tick regex literal had been corrupting the file with NUL bytes.) Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/web/static/index.html | 37 +++++++++- lyra/web/static/style.css | 143 ++++++++++++++++++++++--------------- 2 files changed, 123 insertions(+), 57 deletions(-) diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index 8c73e65..fad6703 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -298,12 +298,47 @@ } } + function renderMarkdown(text) { + var bt = String.fromCharCode(96); + var esc = function (s) { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); }; + var src = String(text == null ? "" : text).replace(/\r\n/g, "\n"); + var blocks = []; + var fenceRe = new RegExp(bt + bt + bt + "[^\\n]*\\n?([\\s\\S]*?)" + bt + bt + bt, "g"); + src = src.replace(fenceRe, function (_, code) { blocks.push(code.replace(/\n+$/, "")); return "@@CB" + (blocks.length - 1) + "@@"; }); + var codeRe = new RegExp(bt + "([^" + bt + "]+)" + bt, "g"); + var inline = function (s) { + return esc(s) + .replace(codeRe, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/\*([^*\n]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1$2'); + }; + var lines = src.split("\n"); + var out = [], para = [], list = null; + var flushPara = function () { if (para.length) { out.push("

" + para.map(inline).join("
") + "

"); para = []; } }; + var flushList = function () { if (list) { out.push("<" + list.t + ">" + list.items.map(function (it) { return "
  • " + inline(it) + "
  • "; }).join("") + ""); list = null; } }; + var flushAll = function () { flushPara(); flushList(); }; + for (var i = 0; i < lines.length; i++) { + var line = lines[i].replace(/\s+$/, ""); var t = line.trim(); var m; + if ((m = t.match(/^@@CB(\d+)@@$/))) { flushAll(); out.push("
    " + esc(blocks[+m[1]]) + "
    "); continue; } + if (!t) { flushAll(); continue; } + if ((m = line.match(/^(#{1,4})\s+(.*)$/))) { flushAll(); out.push("" + inline(m[2]) + ""); continue; } + if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ol") { flushList(); list = { t: "ol", items: [] }; } list.items.push(m[1]); continue; } + if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ul") { flushList(); list = { t: "ul", items: [] }; } list.items.push(m[1]); continue; } + flushList(); para.push(line); + } + flushAll(); + return out.join("\n"); + } + function addMessage(role, text, autoScroll = true) { const messagesEl = document.getElementById("messages"); const msgDiv = document.createElement("div"); msgDiv.className = `msg ${role}`; - msgDiv.textContent = text; + if (role === "assistant") { msgDiv.innerHTML = renderMarkdown(text); } else { msgDiv.textContent = text; } messagesEl.appendChild(msgDiv); // Auto-scroll to bottom if enabled diff --git a/lyra/web/static/style.css b/lyra/web/static/style.css index a93bf8a..fd4fe6f 100644 --- a/lyra/web/static/style.css +++ b/lyra/web/static/style.css @@ -907,59 +907,90 @@ select:hover { display: none !important; } } - -/* ---- Live Log lines ---- */ -.log-line { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px; - padding: 4px 8px; - border-radius: 4px; - font-size: 0.8rem; - font-family: 'Courier New', monospace; - border-left: 3px solid var(--text-fade); - animation: thinkingSlideIn 0.25s ease-out; - word-break: break-word; -} -.log-time { color: var(--text-fade); flex-shrink: 0; } -.log-level { - flex-shrink: 0; - text-transform: uppercase; - font-size: 0.7rem; - font-weight: bold; - letter-spacing: 0.05em; -} -.log-msg { color: var(--text); } -.log-fields { color: var(--text-fade); width: 100%; padding-left: 4px; } - -.log-info { border-left-color: #00bfff; } -.log-info .log-level { color: #7dd3fc; } -.log-debug { border-left-color: #8a2be2; } -.log-debug .log-level { color: #c79cff; } -.log-error { border-left-color: #ff3333; background: rgba(255,51,51,0.08); } -.log-error .log-level, .log-error .log-msg { color: #fca5a5; } -.log-system { border-left-color: #00ff66; } -.log-system .log-level { color: #00ff66; } - -.log-detail { width: 100%; margin-top: 4px; } -.log-detail summary { - cursor: pointer; - color: var(--accent); - font-size: 0.72rem; - user-select: none; -} -.log-detail pre { - margin: 6px 0 0; - padding: 8px; - max-height: 340px; - overflow: auto; - background: rgba(0,0,0,0.25); - border-left: 2px solid var(--accent); - border-radius: 4px; - font-size: 0.72rem; - line-height: 1.4; - white-space: pre-wrap; - word-break: break-word; - color: var(--text); -} + +/* ---- Live Log lines ---- */ +.log-line { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-family: 'Courier New', monospace; + border-left: 3px solid var(--text-fade); + animation: thinkingSlideIn 0.25s ease-out; + word-break: break-word; +} +.log-time { color: var(--text-fade); flex-shrink: 0; } +.log-level { + flex-shrink: 0; + text-transform: uppercase; + font-size: 0.7rem; + font-weight: bold; + letter-spacing: 0.05em; +} +.log-msg { color: var(--text); } +.log-fields { color: var(--text-fade); width: 100%; padding-left: 4px; } + +.log-info { border-left-color: #00bfff; } +.log-info .log-level { color: #7dd3fc; } +.log-debug { border-left-color: #8a2be2; } +.log-debug .log-level { color: #c79cff; } +.log-error { border-left-color: #ff3333; background: rgba(255,51,51,0.08); } +.log-error .log-level, .log-error .log-msg { color: #fca5a5; } +.log-system { border-left-color: #00ff66; } +.log-system .log-level { color: #00ff66; } + +.log-detail { width: 100%; margin-top: 4px; } +.log-detail summary { + cursor: pointer; + color: var(--accent); + font-size: 0.72rem; + user-select: none; +} +.log-detail pre { + margin: 6px 0 0; + padding: 8px; + max-height: 340px; + overflow: auto; + background: rgba(0,0,0,0.25); + border-left: 2px solid var(--accent); + border-radius: 4px; + font-size: 0.72rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + +/* Rendered markdown in Lyra's replies — readable proportional type + structure. */ +.msg.assistant { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + line-height: 1.55; + max-width: 88%; +} +.msg.assistant p { margin: 0 0 10px; } +.msg.assistant p:last-child { margin-bottom: 0; } +.msg.assistant h1, .msg.assistant h2, .msg.assistant h3, .msg.assistant h4 { + margin: 14px 0 6px; line-height: 1.3; color: var(--accent); +} +.msg.assistant h1 { font-size: 1.18rem; } +.msg.assistant h2 { font-size: 1.1rem; } +.msg.assistant h3 { font-size: 1.02rem; } +.msg.assistant h4 { font-size: 0.96rem; } +.msg.assistant ul, .msg.assistant ol { margin: 6px 0 10px; padding-left: 22px; } +.msg.assistant li { margin: 3px 0; } +.msg.assistant li > ul, .msg.assistant li > ol { margin: 3px 0; } +.msg.assistant strong { font-weight: 600; color: var(--text); } +.msg.assistant em { font-style: italic; } +.msg.assistant a { color: var(--accent); text-decoration: underline; } +.msg.assistant code { + font-family: "IBM Plex Mono", monospace; font-size: 0.88em; + background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 4px; +} +.msg.assistant pre { + background: rgba(0,0,0,0.32); border: 1px solid rgba(255,102,0,0.3); + border-radius: 6px; padding: 10px 12px; margin: 8px 0; overflow-x: auto; +} +.msg.assistant pre code { background: none; padding: 0; font-size: 0.85em; }