fix(ui): render markdown in chat

This commit is contained in:
Peter Steinberger
2026-01-04 21:51:26 +01:00
parent 78998dba9e
commit ff605194ef
8 changed files with 243 additions and 11 deletions

View File

@@ -569,10 +569,106 @@
}
.chat-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--chat-text);
line-height: 1.5;
}
.chat-text :where(p, ul, ol, pre, blockquote, table) {
margin: 0;
}
.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote, p + table) {
margin-top: 0.75em;
}
.chat-text :where(ul, ol) {
padding-left: 1.1em;
}
.chat-text :where(li + li) {
margin-top: 0.25em;
}
.chat-text :where(a) {
color: var(--accent);
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
.chat-text :where(a:hover) {
text-decoration-thickness: 3px;
}
.chat-text :where(blockquote) {
border-left: 2px solid rgba(255, 255, 255, 0.14);
padding-left: 12px;
color: var(--muted);
}
:root[data-theme="light"] .chat-text :where(blockquote) {
border-left-color: rgba(16, 24, 40, 0.16);
}
.chat-text :where(hr) {
border: 0;
border-top: 1px solid var(--border);
opacity: 0.6;
margin: 0.9em 0;
}
.chat-text :where(code) {
font-family: var(--font-mono);
font-size: 0.92em;
}
.chat-text :where(:not(pre) > code) {
padding: 0.15em 0.35em;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
}
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
background: rgba(16, 24, 40, 0.05);
}
.chat-text :where(pre) {
margin-top: 0.75em;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.22);
overflow: auto;
}
:root[data-theme="light"] .chat-text :where(pre) {
background: rgba(16, 24, 40, 0.04);
}
.chat-text :where(pre code) {
font-size: 12px;
white-space: pre;
}
.chat-text :where(table) {
margin-top: 0.75em;
border-collapse: collapse;
width: 100%;
font-size: 12px;
}
.chat-text :where(th, td) {
border: 1px solid var(--border);
padding: 6px 8px;
vertical-align: top;
}
.chat-text :where(th) {
font-family: var(--font-mono);
font-weight: 600;
color: var(--muted);
}
.chat-tool-card {

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { toSanitizedMarkdownHtml } from "./markdown";
describe("toSanitizedMarkdownHtml", () => {
it("renders basic markdown", () => {
const html = toSanitizedMarkdownHtml("Hello **world**");
expect(html).toContain("<strong>world</strong>");
});
it("strips scripts and unsafe links", () => {
const html = toSanitizedMarkdownHtml(
[
"<script>alert(1)</script>",
"",
"[x](javascript:alert(1))",
"",
"[ok](https://example.com)",
].join("\n"),
);
expect(html).not.toContain("<script");
expect(html).not.toContain("javascript:");
expect(html).toContain("https://example.com");
});
it("renders fenced code blocks", () => {
const html = toSanitizedMarkdownHtml(["```ts", "console.log(1)", "```"].join("\n"));
expect(html).toContain("<pre>");
expect(html).toContain("<code");
expect(html).toContain("console.log(1)");
});
});

66
ui/src/ui/markdown.ts Normal file
View File

@@ -0,0 +1,66 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
marked.setOptions({
gfm: true,
breaks: true,
headerIds: false,
mangle: false,
});
const allowedTags = [
"a",
"b",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"hr",
"i",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
];
const allowedAttrs = ["class", "href", "rel", "target", "title"];
let hooksInstalled = false;
function installHooks() {
if (hooksInstalled) return;
hooksInstalled = true;
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
const href = node.getAttribute("href");
if (!href) return;
node.setAttribute("rel", "noreferrer noopener");
node.setAttribute("target", "_blank");
});
}
export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim();
if (!input) return "";
installHooks();
const rendered = marked.parse(input) as string;
return DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
}

View File

@@ -1,6 +1,8 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { SessionsListResult } from "../types";
import { toSanitizedMarkdownHtml } from "../markdown";
import { resolveToolDisplay, formatToolDetail } from "../tool-display";
export type ChatProps = {
@@ -178,13 +180,19 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
const extractedText = extractText(message);
const contentText = typeof m.content === "string" ? m.content : null;
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
const text = !isToolResult
? extractedText?.trim()
? extractedText
: contentText?.trim()
? contentText
: fallback
: null;
const display =
!isToolResult && extractedText?.trim()
? { kind: "text" as const, value: extractedText }
: !isToolResult && contentText?.trim()
? { kind: "text" as const, value: contentText }
: !isToolResult && fallback
? { kind: "json" as const, value: fallback }
: null;
const markdown =
display?.kind === "json"
? ["```json", display.value, "```"].join("\n")
: display?.value ?? null;
const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
@@ -194,7 +202,9 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
<div class="chat-line ${klass}">
<div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
${text ? html`<div class="chat-text">${text}</div>` : nothing}
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing}
${toolCards.map((card) => renderToolCard(card))}
</div>
<div class="chat-stamp mono">