@@ -162,17 +162,190 @@ function setBusy(isBusy, reason = "") {
162162 setStatus ( isBusy ? ( reason || "Working…" ) : "Ready" ) ;
163163}
164164
165+ function escapeHtml ( text ) {
166+ return text
167+ . replaceAll ( "&" , "&" )
168+ . replaceAll ( "<" , "<" )
169+ . replaceAll ( ">" , ">" )
170+ . replaceAll ( '"' , """ )
171+ . replaceAll ( "'" , "'" ) ;
172+ }
173+
174+ function sanitizeUrl ( url ) {
175+ try {
176+ const parsed = new URL ( url , window . location . href ) ;
177+ if ( [ "http:" , "https:" , "mailto:" ] . includes ( parsed . protocol ) ) {
178+ return parsed . href ;
179+ }
180+ } catch {
181+ }
182+ return null ;
183+ }
184+
185+ function renderInlineMarkdown ( text ) {
186+ const placeholders = [ ] ;
187+ const addPlaceholder = ( html ) => {
188+ const token = `@@MD${ placeholders . length } @@` ;
189+ placeholders . push ( { token, html } ) ;
190+ return token ;
191+ } ;
192+
193+ let rendered = text ;
194+
195+ rendered = rendered . replace ( / ` ( [ ^ ` ] + ) ` / g, ( _ , code ) => addPlaceholder ( `<code>${ escapeHtml ( code ) } </code>` ) ) ;
196+ rendered = rendered . replace ( / \[ ( [ ^ \] ] + ) \] \( ( [ ^ ) \s ] + ) \) / g, ( _ , label , url ) => {
197+ const href = sanitizeUrl ( url ) ;
198+ if ( ! href ) {
199+ return `${ label } (${ url } )` ;
200+ }
201+ return addPlaceholder (
202+ `<a href="${ escapeHtml ( href ) } " target="_blank" rel="noreferrer noopener">${ escapeHtml ( label ) } </a>`
203+ ) ;
204+ } ) ;
205+
206+ rendered = escapeHtml ( rendered ) ;
207+ rendered = rendered . replace ( / \* \* ( [ ^ * ] + ) \* \* / g, "<strong>$1</strong>" ) ;
208+ rendered = rendered . replace ( / \* ( [ ^ * ] + ) \* / g, "<em>$1</em>" ) ;
209+ rendered = rendered . replace ( / _ ( [ ^ _ ] + ) _ / g, "<em>$1</em>" ) ;
210+
211+ for ( const placeholder of placeholders ) {
212+ rendered = rendered . replaceAll ( placeholder . token , placeholder . html ) ;
213+ }
214+
215+ return rendered ;
216+ }
217+
218+ function renderMarkdown ( text ) {
219+ const lines = String ( text ) . replace ( / \r \n ? / g, "\n" ) . split ( "\n" ) ;
220+ const html = [ ] ;
221+ let paragraphLines = [ ] ;
222+ let quoteLines = [ ] ;
223+ let listType = null ;
224+ let listItems = [ ] ;
225+
226+ const flushParagraph = ( ) => {
227+ if ( ! paragraphLines . length ) return ;
228+ html . push ( `<p>${ renderInlineMarkdown ( paragraphLines . join ( " " ) ) } </p>` ) ;
229+ paragraphLines = [ ] ;
230+ } ;
231+
232+ const flushQuote = ( ) => {
233+ if ( ! quoteLines . length ) return ;
234+ const quoteBody = quoteLines . map ( ( line ) => renderInlineMarkdown ( line ) ) . join ( "<br />" ) ;
235+ html . push ( `<blockquote><p>${ quoteBody } </p></blockquote>` ) ;
236+ quoteLines = [ ] ;
237+ } ;
238+
239+ const flushList = ( ) => {
240+ if ( ! listItems . length || ! listType ) return ;
241+ const items = listItems . map ( ( item ) => `<li>${ renderInlineMarkdown ( item ) } </li>` ) . join ( "" ) ;
242+ html . push ( `<${ listType } >${ items } </${ listType } >` ) ;
243+ listType = null ;
244+ listItems = [ ] ;
245+ } ;
246+
247+ const flushAll = ( ) => {
248+ flushParagraph ( ) ;
249+ flushQuote ( ) ;
250+ flushList ( ) ;
251+ } ;
252+
253+ for ( let index = 0 ; index < lines . length ; index ++ ) {
254+ const line = lines [ index ] ;
255+ const trimmed = line . trim ( ) ;
256+
257+ if ( trimmed . startsWith ( "```" ) ) {
258+ flushAll ( ) ;
259+ const language = trimmed . slice ( 3 ) . trim ( ) ;
260+ const codeLines = [ ] ;
261+ index += 1 ;
262+ while ( index < lines . length && ! lines [ index ] . trim ( ) . startsWith ( "```" ) ) {
263+ codeLines . push ( lines [ index ] ) ;
264+ index += 1 ;
265+ }
266+ const languageClass = language ? ` class="language-${ escapeHtml ( language ) } "` : "" ;
267+ html . push ( `<pre><code${ languageClass } >${ escapeHtml ( codeLines . join ( "\n" ) ) } </code></pre>` ) ;
268+ continue ;
269+ }
270+
271+ if ( ! trimmed ) {
272+ flushAll ( ) ;
273+ continue ;
274+ }
275+
276+ const headingMatch = trimmed . match ( / ^ ( # { 1 , 6 } ) \s + ( .+ ) $ / ) ;
277+ if ( headingMatch ) {
278+ flushAll ( ) ;
279+ const level = headingMatch [ 1 ] . length ;
280+ html . push ( `<h${ level } >${ renderInlineMarkdown ( headingMatch [ 2 ] ) } </h${ level } >` ) ;
281+ continue ;
282+ }
283+
284+ const quoteMatch = trimmed . match ( / ^ > \s ? ( .* ) $ / ) ;
285+ if ( quoteMatch ) {
286+ flushParagraph ( ) ;
287+ flushList ( ) ;
288+ quoteLines . push ( quoteMatch [ 1 ] ) ;
289+ continue ;
290+ }
291+
292+ if ( quoteLines . length ) {
293+ flushQuote ( ) ;
294+ }
295+
296+ const unorderedListMatch = trimmed . match ( / ^ [ - * ] \s + ( .+ ) $ / ) ;
297+ if ( unorderedListMatch ) {
298+ flushParagraph ( ) ;
299+ if ( listType && listType !== "ul" ) {
300+ flushList ( ) ;
301+ }
302+ listType = "ul" ;
303+ listItems . push ( unorderedListMatch [ 1 ] ) ;
304+ continue ;
305+ }
306+
307+ const orderedListMatch = trimmed . match ( / ^ \d + \. \s + ( .+ ) $ / ) ;
308+ if ( orderedListMatch ) {
309+ flushParagraph ( ) ;
310+ if ( listType && listType !== "ol" ) {
311+ flushList ( ) ;
312+ }
313+ listType = "ol" ;
314+ listItems . push ( orderedListMatch [ 1 ] ) ;
315+ continue ;
316+ }
317+
318+ if ( listItems . length ) {
319+ flushList ( ) ;
320+ }
321+
322+ paragraphLines . push ( trimmed ) ;
323+ }
324+
325+ flushAll ( ) ;
326+
327+ return html . join ( "" ) ;
328+ }
329+
165330function addMessage ( role , text ) {
166331 if ( text . ok ) {
167332 text = text . data ;
168333 }
334+ if ( typeof text !== "string" ) {
335+ text = JSON . stringify ( text , null , 2 ) ;
336+ }
169337 const wrap = document . createElement ( "div" ) ;
170338 wrap . className = `msg ${ role } ` ;
171339 wrap . innerHTML = `
172340 <div class="role ${ role } ">${ role } ${ role === "tool" ? '<span class="chevron">▶</span>' : '' } </div>
173341 <div class="bubble"></div>` ;
174342 const bubble = wrap . querySelector ( ".bubble" ) ;
175- bubble . textContent = text ;
343+ if ( role === "assistant" ) {
344+ bubble . classList . add ( "markdown-content" ) ;
345+ bubble . innerHTML = renderMarkdown ( text ) ;
346+ } else {
347+ bubble . textContent = text ;
348+ }
176349 bubble . onclick = function ( ) {
177350 if ( bubble . scrollHeight > 100 && role === "tool" ) {
178351 const expanded = bubble . style . maxHeight === 'none' ;
0 commit comments