@@ -5,7 +5,7 @@ import { property } from "lit/decorators.js";
55import ClipboardJS from "clipboard" ;
66import { sanitize } from "dompurify" ;
77import hljs from "highlight.js/lib/common" ;
8- import { parse } from "marked" ;
8+ import { Renderer , parse } from "marked" ;
99
1010import { 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 ( "&" , "&" )
70+ . replaceAll ( "<" , "<" )
71+ . replaceAll ( ">" , ">" )
72+ . replaceAll ( '"' , """ )
73+ . replaceAll ( "'" , "'" ) ;
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
6094class 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