feat(web): render Lyra's replies as Markdown (readable, not a wall of asterisks)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:39:52 +00:00
parent 8c2bdbe0d5
commit ce65755d9c
2 changed files with 123 additions and 57 deletions
+36 -1
View File
@@ -298,12 +298,47 @@
} }
} }
function renderMarkdown(text) {
var bt = String.fromCharCode(96);
var esc = function (s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); };
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, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
};
var lines = src.split("\n");
var out = [], para = [], list = null;
var flushPara = function () { if (para.length) { out.push("<p>" + para.map(inline).join("<br>") + "</p>"); para = []; } };
var flushList = function () { if (list) { out.push("<" + list.t + ">" + list.items.map(function (it) { return "<li>" + inline(it) + "</li>"; }).join("") + "</" + list.t + ">"); 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("<pre><code>" + esc(blocks[+m[1]]) + "</code></pre>"); continue; }
if (!t) { flushAll(); continue; }
if ((m = line.match(/^(#{1,4})\s+(.*)$/))) { flushAll(); out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); 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) { function addMessage(role, text, autoScroll = true) {
const messagesEl = document.getElementById("messages"); const messagesEl = document.getElementById("messages");
const msgDiv = document.createElement("div"); const msgDiv = document.createElement("div");
msgDiv.className = `msg ${role}`; msgDiv.className = `msg ${role}`;
msgDiv.textContent = text; if (role === "assistant") { msgDiv.innerHTML = renderMarkdown(text); } else { msgDiv.textContent = text; }
messagesEl.appendChild(msgDiv); messagesEl.appendChild(msgDiv);
// Auto-scroll to bottom if enabled // Auto-scroll to bottom if enabled
+31
View File
@@ -963,3 +963,34 @@ select:hover {
word-break: break-word; word-break: break-word;
color: var(--text); 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; }