Skip to content

Commit f68cc52

Browse files
committed
Render (most) markdown in chat input
1 parent 578e375 commit f68cc52

File tree

3 files changed

+67
-41
lines changed

3 files changed

+67
-41
lines changed

js/chat/chat.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { property } from "lit/decorators.js";
55
import ClipboardJS from "clipboard";
66
import { sanitize } from "dompurify";
77
import hljs from "highlight.js/lib/common";
8-
import { parse } from "marked";
8+
import { Renderer, parse } from "marked";
99

1010
import { createElement } from "./_utils";
1111

@@ -56,6 +56,40 @@ const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
5656
);
5757
};
5858

59+
// For rendering chat output, we use typical Markdown behavior of passing through raw
60+
// HTML (albeit sanitizing afterwards).
61+
//
62+
// For echoing chat input, we escape HTML. This is not for security reasons but just
63+
// because it's confusing if the user is using tag-like syntax to demarcate parts of
64+
// their prompt for other reasons (like <User>/<Assistant> for providing examples to the
65+
// chat model), and those tags simply vanish.
66+
const rendererEscapeHTML = new Renderer();
67+
rendererEscapeHTML.html = (html: string) =>
68+
html
69+
.replaceAll("&", "&amp;")
70+
.replaceAll("<", "&lt;")
71+
.replaceAll(">", "&gt;")
72+
.replaceAll('"', "&quot;")
73+
.replaceAll("'", "&#039;");
74+
const markedEscapeOpts = { renderer: rendererEscapeHTML };
75+
76+
function contentToHTML(
77+
content: string,
78+
content_type: "markdown" | "semi-markdown" | "html" | "text"
79+
) {
80+
if (content_type === "markdown") {
81+
return unsafeHTML(sanitize(parse(content) as string));
82+
} else if (content_type === "semi-markdown") {
83+
return unsafeHTML(sanitize(parse(content, markedEscapeOpts) as string));
84+
} else if (content_type === "html") {
85+
return unsafeHTML(sanitize(content));
86+
} else if (content_type === "text") {
87+
return content;
88+
} else {
89+
throw new Error(`Unknown content type: ${content_type}`);
90+
}
91+
}
92+
5993
// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
6094
class LightElement extends LitElement {
6195
createRenderRoot() {
@@ -69,16 +103,7 @@ class ChatMessage extends LightElement {
69103
@property({ type: Boolean, reflect: true }) is_streaming = false;
70104

71105
render(): ReturnType<LitElement["render"]> {
72-
let content;
73-
if (this.content_type === "markdown") {
74-
content = unsafeHTML(sanitize(parse(this.content) as string));
75-
} else if (this.content_type === "html") {
76-
content = unsafeHTML(sanitize(this.content));
77-
} else if (this.content_type === "text") {
78-
content = this.content;
79-
} else {
80-
throw new Error(`Unknown content type: ${this.content_type}`);
81-
}
106+
const content = contentToHTML(this.content, this.content_type);
82107

83108
// TODO: support custom icons
84109
const icon =
@@ -131,7 +156,7 @@ class ChatUserMessage extends LightElement {
131156
@property() content = "...";
132157

133158
render(): ReturnType<LitElement["render"]> {
134-
return html`${this.content}`;
159+
return contentToHTML(this.content, "semi-markdown");
135160
}
136161
}
137162

@@ -353,7 +378,6 @@ class ChatContainer extends LightElement {
353378
#appendMessageChunk(message: Message): void {
354379
if (message.chunk_type === "message_start") {
355380
this.#appendMessage(message, false);
356-
return;
357381
}
358382

359383
const lastMessage = this.messages.lastElementChild as HTMLElement;
@@ -363,10 +387,12 @@ class ChatContainer extends LightElement {
363387
lastMessage.removeAttribute("is_streaming");
364388
this.#finalizeMessage();
365389
return;
390+
} else {
391+
lastMessage.setAttribute("is_streaming", "");
392+
if (!message.chunk_type) {
393+
lastMessage.setAttribute("content", message.content);
394+
}
366395
}
367-
368-
lastMessage.setAttribute("is_streaming", "");
369-
lastMessage.setAttribute("content", message.content);
370396
}
371397

372398
#onClear(): void {

0 commit comments

Comments
 (0)