@@ -5,7 +5,7 @@ import { property } from "lit/decorators.js";
5
5
import ClipboardJS from "clipboard" ;
6
6
import { sanitize } from "dompurify" ;
7
7
import hljs from "highlight.js/lib/common" ;
8
- import { parse } from "marked" ;
8
+ import { Renderer , parse } from "marked" ;
9
9
10
10
import { createElement } from "./_utils" ;
11
11
@@ -51,6 +51,17 @@ const CHAT_MESSAGES_TAG = "shiny-chat-messages";
51
51
const CHAT_INPUT_TAG = "shiny-chat-input" ;
52
52
const CHAT_CONTAINER_TAG = "shiny-chat-container" ;
53
53
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
+
54
65
const requestScroll = ( el : HTMLElement , cancelIfScrolledUp = false ) => {
55
66
el . dispatchEvent (
56
67
new CustomEvent ( "shiny-chat-request-scroll" , {
@@ -61,6 +72,40 @@ const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
61
72
) ;
62
73
} ;
63
74
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
+
64
109
// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
65
110
class LightElement extends LitElement {
66
111
createRenderRoot ( ) {
@@ -69,29 +114,20 @@ class LightElement extends LitElement {
69
114
}
70
115
71
116
class ChatMessage extends LightElement {
72
- @property ( ) content = "... " ;
117
+ @property ( ) content = "" ;
73
118
@property ( ) content_type : ContentType = "markdown" ;
74
- @property ( { type : Boolean , reflect : true } ) is_streaming = false ;
119
+ @property ( { type : Boolean , reflect : true } ) streaming = false ;
75
120
76
121
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 ) ;
87
123
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 ;
91
126
92
127
return html `
93
128
< div class ="message-icon "> ${ unsafeHTML ( icon ) } </ div >
94
129
< div class ="message-content "> ${ content } </ div >
130
+ < div class ="message-streaming-icon "> ${ unsafeHTML ( ICONS . ball_bounce ) } </ div >
95
131
` ;
96
132
}
97
133
@@ -100,7 +136,7 @@ class ChatMessage extends LightElement {
100
136
this . #highlightAndCodeCopy( ) ;
101
137
// It's important that the scroll request happens at this point in time, since
102
138
// otherwise, the content may not be fully rendered yet
103
- requestScroll ( this , this . is_streaming ) ;
139
+ requestScroll ( this , this . streaming ) ;
104
140
}
105
141
}
106
142
@@ -136,7 +172,7 @@ class ChatUserMessage extends LightElement {
136
172
@property ( ) content = "..." ;
137
173
138
174
render ( ) : ReturnType < LitElement [ "render" ] > {
139
- return html ` ${ this . content } ` ;
175
+ return contentToHTML ( this . content , "semi-markdown" ) ;
140
176
}
141
177
}
142
178
@@ -251,6 +287,11 @@ class ChatContainer extends LightElement {
251
287
return this . querySelector ( CHAT_MESSAGES_TAG ) as ChatMessages ;
252
288
}
253
289
290
+ private get lastMessage ( ) : ChatMessage | null {
291
+ const last = this . messages . lastElementChild ;
292
+ return last ? ( last as ChatMessage ) : null ;
293
+ }
294
+
254
295
private resizeObserver ! : ResizeObserver ;
255
296
256
297
render ( ) : ReturnType < LitElement [ "render" ] > {
@@ -341,9 +382,7 @@ class ChatContainer extends LightElement {
341
382
342
383
#addLoadingMessage( ) : void {
343
384
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 : "" ,
347
386
role : "assistant" ,
348
387
id : `${ this . id } -loading-message` ,
349
388
} ;
@@ -364,21 +403,21 @@ class ChatContainer extends LightElement {
364
403
#appendMessageChunk( message : Message ) : void {
365
404
if ( message . chunk_type === "message_start" ) {
366
405
this . #appendMessage( message , false ) ;
367
- return ;
368
406
}
369
407
370
- const lastMessage = this . messages . lastElementChild as HTMLElement ;
408
+ const lastMessage = this . lastMessage ;
371
409
if ( ! lastMessage ) throw new Error ( "No messages found in the chat output" ) ;
372
410
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" , "" ) ;
377
413
return ;
378
414
}
379
415
380
- lastMessage . setAttribute ( "is_streaming" , "" ) ;
381
416
lastMessage . setAttribute ( "content" , message . content ) ;
417
+
418
+ if ( message . chunk_type === "message_end" ) {
419
+ this . #finalizeMessage( ) ;
420
+ }
382
421
}
383
422
384
423
#onClear( ) : void {
@@ -402,6 +441,7 @@ class ChatContainer extends LightElement {
402
441
403
442
#finalizeMessage( ) : void {
404
443
this . input . disabled = false ;
444
+ this . lastMessage ?. removeAttribute ( "streaming" ) ;
405
445
}
406
446
407
447
#onRequestScroll( event : CustomEvent < requestScrollEvent > ) : void {
0 commit comments