@@ -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
@@ -51,6 +51,17 @@ const CHAT_MESSAGES_TAG = "shiny-chat-messages";
5151const CHAT_INPUT_TAG = "shiny-chat-input" ;
5252const CHAT_CONTAINER_TAG = "shiny-chat-container" ;
5353
54+ const ICONS = {
55+ robot :
56+ '<svg fill="currentColor" class="bi bi-robot" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135"/><path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5"/></svg>' ,
57+ // https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
58+ dots_fade :
59+ '<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>' ,
60+ // https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/bouncing-ball.svg
61+ ball_bounce :
62+ '<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_rXNP{animation:spinner_YeBj .8s infinite; opacity:.8}@keyframes spinner_YeBj{0%{animation-timing-function:cubic-bezier(0.33,0,.66,.33);cy:5px}46.875%{cy:20px;rx:4px;ry:4px}50%{animation-timing-function:cubic-bezier(0.33,.66,.66,1);cy:20.5px;rx:4.8px;ry:3px}53.125%{rx:4px;ry:4px}100%{cy:5px}}</style><ellipse class="spinner_rXNP" cx="12" cy="5" rx="4" ry="4"/></svg>' ,
63+ } ;
64+
5465const requestScroll = ( el : HTMLElement , cancelIfScrolledUp = false ) => {
5566 el . dispatchEvent (
5667 new CustomEvent ( "shiny-chat-request-scroll" , {
@@ -61,6 +72,40 @@ const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
6172 ) ;
6273} ;
6374
75+ // For rendering chat output, we use typical Markdown behavior of passing through raw
76+ // HTML (albeit sanitizing afterwards).
77+ //
78+ // For echoing chat input, we escape HTML. This is not for security reasons but just
79+ // because it's confusing if the user is using tag-like syntax to demarcate parts of
80+ // their prompt for other reasons (like <User>/<Assistant> for providing examples to the
81+ // chat model), and those tags simply vanish.
82+ const rendererEscapeHTML = new Renderer ( ) ;
83+ rendererEscapeHTML . html = ( html : string ) =>
84+ html
85+ . replaceAll ( "&" , "&" )
86+ . replaceAll ( "<" , "<" )
87+ . replaceAll ( ">" , ">" )
88+ . replaceAll ( '"' , """ )
89+ . replaceAll ( "'" , "'" ) ;
90+ const markedEscapeOpts = { renderer : rendererEscapeHTML } ;
91+
92+ function contentToHTML (
93+ content : string ,
94+ content_type : ContentType | "semi-markdown"
95+ ) {
96+ if ( content_type === "markdown" ) {
97+ return unsafeHTML ( sanitize ( parse ( content ) as string ) ) ;
98+ } else if ( content_type === "semi-markdown" ) {
99+ return unsafeHTML ( sanitize ( parse ( content , markedEscapeOpts ) as string ) ) ;
100+ } else if ( content_type === "html" ) {
101+ return unsafeHTML ( sanitize ( content ) ) ;
102+ } else if ( content_type === "text" ) {
103+ return content ;
104+ } else {
105+ throw new Error ( `Unknown content type: ${ content_type } ` ) ;
106+ }
107+ }
108+
64109// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
65110class LightElement extends LitElement {
66111 createRenderRoot ( ) {
@@ -69,29 +114,20 @@ class LightElement extends LitElement {
69114}
70115
71116class ChatMessage extends LightElement {
72- @property ( ) content = "... " ;
117+ @property ( ) content = "" ;
73118 @property ( ) content_type : ContentType = "markdown" ;
74- @property ( { type : Boolean , reflect : true } ) is_streaming = false ;
119+ @property ( { type : Boolean , reflect : true } ) streaming = false ;
75120
76121 render ( ) : ReturnType < LitElement [ "render" ] > {
77- let content ;
78- if ( this . content_type === "markdown" ) {
79- content = unsafeHTML ( sanitize ( parse ( this . content ) as string ) ) ;
80- } else if ( this . content_type === "html" ) {
81- content = unsafeHTML ( sanitize ( this . content ) ) ;
82- } else if ( this . content_type === "text" ) {
83- content = this . content ;
84- } else {
85- throw new Error ( `Unknown content type: ${ this . content_type } ` ) ;
86- }
122+ const content = contentToHTML ( this . content , this . content_type ) ;
87123
88- // TODO: support custom icons
89- const icon =
90- '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-robot" viewBox="0 0 16 16"><path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135"/><path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5"/></svg>' ;
124+ const noContent = this . content . trim ( ) . length === 0 ;
125+ const icon = noContent ? ICONS . dots_fade : ICONS . robot ;
91126
92127 return html `
93128 < div class ="message-icon "> ${ unsafeHTML ( icon ) } </ div >
94129 < div class ="message-content "> ${ content } </ div >
130+ < div class ="message-streaming-icon "> ${ unsafeHTML ( ICONS . ball_bounce ) } </ div >
95131 ` ;
96132 }
97133
@@ -100,7 +136,7 @@ class ChatMessage extends LightElement {
100136 this . #highlightAndCodeCopy( ) ;
101137 // It's important that the scroll request happens at this point in time, since
102138 // otherwise, the content may not be fully rendered yet
103- requestScroll ( this , this . is_streaming ) ;
139+ requestScroll ( this , this . streaming ) ;
104140 }
105141 }
106142
@@ -136,7 +172,7 @@ class ChatUserMessage extends LightElement {
136172 @property ( ) content = "..." ;
137173
138174 render ( ) : ReturnType < LitElement [ "render" ] > {
139- return html ` ${ this . content } ` ;
175+ return contentToHTML ( this . content , "semi-markdown" ) ;
140176 }
141177}
142178
@@ -251,6 +287,11 @@ class ChatContainer extends LightElement {
251287 return this . querySelector ( CHAT_MESSAGES_TAG ) as ChatMessages ;
252288 }
253289
290+ private get lastMessage ( ) : ChatMessage | null {
291+ const last = this . messages . lastElementChild ;
292+ return last ? ( last as ChatMessage ) : null ;
293+ }
294+
254295 private resizeObserver ! : ResizeObserver ;
255296
256297 render ( ) : ReturnType < LitElement [ "render" ] > {
@@ -341,9 +382,7 @@ class ChatContainer extends LightElement {
341382
342383 #addLoadingMessage( ) : void {
343384 const loading_message = {
344- // https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
345- content :
346- '<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>' ,
385+ content : "" ,
347386 role : "assistant" ,
348387 id : `${ this . id } -loading-message` ,
349388 } ;
@@ -364,21 +403,21 @@ class ChatContainer extends LightElement {
364403 #appendMessageChunk( message : Message ) : void {
365404 if ( message . chunk_type === "message_start" ) {
366405 this . #appendMessage( message , false ) ;
367- return ;
368406 }
369407
370- const lastMessage = this . messages . lastElementChild as HTMLElement ;
408+ const lastMessage = this . lastMessage ;
371409 if ( ! lastMessage ) throw new Error ( "No messages found in the chat output" ) ;
372410
373- if ( message . chunk_type === "message_end" ) {
374- lastMessage . removeAttribute ( "is_streaming" ) ;
375- lastMessage . setAttribute ( "content" , message . content ) ;
376- this . #finalizeMessage( ) ;
411+ if ( message . chunk_type === "message_start" ) {
412+ lastMessage . setAttribute ( "streaming" , "" ) ;
377413 return ;
378414 }
379415
380- lastMessage . setAttribute ( "is_streaming" , "" ) ;
381416 lastMessage . setAttribute ( "content" , message . content ) ;
417+
418+ if ( message . chunk_type === "message_end" ) {
419+ this . #finalizeMessage( ) ;
420+ }
382421 }
383422
384423 #onClear( ) : void {
@@ -402,6 +441,7 @@ class ChatContainer extends LightElement {
402441
403442 #finalizeMessage( ) : void {
404443 this . input . disabled = false ;
444+ this . lastMessage ?. removeAttribute ( "streaming" ) ;
405445 }
406446
407447 #onRequestScroll( event : CustomEvent < requestScrollEvent > ) : void {
0 commit comments