1// static/js/markdown.js 2 3/** 4 * Markdown rendering and content processing utilities 5 */ 6 7import uiModule from './ui.js'; 8import { splitTableRow } from './markdown/tableRow.js'; 9import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js'; 10 11var escapeHtml = uiModule.esc; 12 13function safeLinkUrl(rawUrl) { 14 const url = String(rawUrl || '').trim(); 15 if (url.startsWith('#')) { 16 return /^#[A-Za-z0-9_-]*$/.test(url) ? url : ''; 17 } 18 try { 19 const parsed = new URL(url, window.location.origin); 20 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { 21 return parsed.href; 22 } 23 } catch (_) { 24 return ''; 25 } 26 return ''; 27} 28 29function linkHtml(text, url) { 30 const safeUrl = safeLinkUrl(url); 31 const safeText = escapeHtml(text); 32 if (!safeUrl) return safeText; 33 if (safeUrl.startsWith('#')) { 34 return `<a href="${safeUrl}" class="chat-link">${safeText}</a>`; 35 } 36 return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`; 37} 38 39/** 40 * Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from 41 * the source text â <details> blocks (collapsible agent output) and <a> tags 42 * (emitted by the markdown link pass). Those fragments are later restored 43 * verbatim into innerHTML, so without scrubbing them a model â or any content 44 * routed through here â could smuggle in an `<img onerror=...>`, an 45 * `<a href="javascript:...">`, an `onmouseover=` handler, etc. and execute 46 * script in the authenticated page (DOM XSS). 47 * 48 * Parsing into a <template> is inert: assigning to template.innerHTML neither 49 * fetches resources nor runs scripts, so we can walk the resulting tree, 50 * drop script-capable elements, and strip event-handler attributes and 51 * dangerous URL schemes before the (now safe) fragment is handed back. 52 */ 53const _ALLOWED_HTML_BAD_TAGS = new Set([ 54 'SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 55 'STYLE', 'BASE', 'FORM', 'NOSCRIPT', 'TEMPLATE', 56 // Foreign-content roots. SVG/MathML have their own parser rules and are a 57 // classic mutation-XSS vehicle â e.g. an SVG-namespaced <script>, whose 58 // `tagName` is the lower-case 'script' and would slip a name check that 59 // assumed HTML's upper-casing. They aren't needed in the <details>/<a> 60 // fragments we preserve, so drop the whole subtree. 61 'SVG', 'MATH', 62]); 63const _ALLOWED_HTML_URL_ATTRS = new Set([ 64 'href', 'src', 'srcset', 'xlink:href', 'action', 'formaction', 'background', 'poster', 65]); 66 67function _compactUrlSchemeValue(value) { 68 return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase(); 69} 70 71function _isDangerousUrl(value) { 72 return /^(javascript|vbscript|data):/.test(_compactUrlSchemeValue(value)); 73} 74 75function _isDangerousSrcset(value) { 76 return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate)); 77} 78 79function _cleanAllowedHtmlOnce(htmlString) { 80 const tpl = document.createElement('template'); 81 tpl.innerHTML = htmlString; 82 for (const el of Array.from(tpl.content.querySelectorAll('*'))) { 83 // Upper-case the tag for comparison: HTML tagNames are upper-case, but 84 // SVG/MathML elements preserve their original (lower/camel) case, so a 85 // raw `Set.has(el.tagName)` would miss e.g. a namespaced <script>. 86 if (_ALLOWED_HTML_BAD_TAGS.has(el.tagName.toUpperCase())) { 87 el.remove(); 88 continue; 89 } 90 for (const attr of Array.from(el.attributes)) { 91 const name = attr.name.toLowerCase(); 92 // Drop every inline event handler (onerror, onclick, onmouseover, ...) 93 // and srcdoc (a frame-less script vector). 94 if (name.startsWith('on') || name === 'srcdoc') { 95 el.removeAttribute(attr.name); 96 continue; 97 } 98 if (name === 'style') { 99 const value = _compactUrlSchemeValue(attr.value); 100 if (/javascript:|vbscript:|data:|expression\(/.test(value)) { 101 el.removeAttribute(attr.name); 102 } 103 continue; 104 } 105 // Neutralize javascript:/vbscript:/data: in URL-bearing attributes. 106 // Strip control/space chars first so e.g. "java\tscript:" can't slip by. 107 if (_ALLOWED_HTML_URL_ATTRS.has(name)) { 108 if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) { 109 el.removeAttribute(attr.name); 110 } 111 } 112 } 113 } 114 return tpl.innerHTML; 115} 116 117function sanitizeAllowedHtml(html) { 118 const raw = String(html == null ? '' : html); 119 // Non-browser context (e.g. a future SSR/Node import): fail closed by 120 // escaping rather than trusting the markup. 121 if (typeof document === 'undefined') return escapeHtml(raw); 122 123 // Sanitize to a fixpoint. Re-parsing the serialized output can mutate the 124 // tree (the basis of mutation-XSS), so re-clean until it stops changing. 125 let out = raw; 126 for (let i = 0; i < 4; i++) { 127 const next = _cleanAllowedHtmlOnce(out); 128 if (next === out) break; 129 out = next; 130 } 131 return out; 132} 133 134/** 135 * Check if text has unclosed think tag 136 */ 137export function hasUnclosedThinkTag(text) { 138 text = text || ''; 139 const openCount = 140 (text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length 141 + (text.match(/<\|channel>thought/gi) || []).length; 142 const closeCount = 143 (text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length 144 + (text.match(/<channel\|>/gi) || []).length; 145 return openCount > closeCount; 146} 147 148export function startsWithReasoningPrefix(text) { 149 return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || ''); 150} 151 152export function normalizeThinkingMarkup(text) { 153 if (!text) return text; 154 let normalized = text; 155 normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`); 156 normalized = normalized.replace(/<\/thought>/gi, '</think>'); 157 normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => { 158 const thought = String(content || '').trim(); 159 return thought ? `<think>${thought}</think>\n` : ''; 160 }); 161 normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || ''); 162 normalized = normalized.replace(/<\|channel>response\s*\n?/gi, ''); 163 normalized = normalized.replace(/<channel\|>/gi, ''); 164 return normalized; 165} 166 167function normalizePlainThinking(text) { 168 if (!text) return text; 169 text = normalizeThinkingMarkup(text); 170 if (/<think/i.test(text)) return text; 171 172 const trimmed = text.trimStart(); 173 if (!startsWithReasoningPrefix(trimmed)) return text; 174 175 const replyStarts = [
176 'Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 177 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 178 'Good ', "I'm happy", "I'd be" 179 ]; 180 const prefixRegex = /^(thinking(?:\s+process)?\s*:)\s*/i; 181 const escapedReplyStarts = replyStarts.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); 182 const boundaryRegex = new RegExp( 183 `^([\\s\\S]*?)(\\n\\n(?=${escapedReplyStarts.join('|')}|I |What|Let|This |As ))[\\s\\S]*$`, 184 'i' 185 ); 186 const boundaryMatch = boundaryRegex.exec(trimmed); 187 188 if (boundaryMatch) { 189 const thinkBlock = boundaryMatch[1].replace(prefixRegex, '').trim(); 190 const reply = trimmed.slice(boundaryMatch[1].length).trimStart(); 191 if (thinkBlock && reply) return `<think>${thinkBlock}</think>\n\n${reply}`; 192 } 193 194 const lines = trimmed.split('\n'); 195 for (let index = 1; index < lines.length; index += 1) { 196 const line = lines[index].trim(); 197 if (!line) continue; 198 if (replyStarts.some((prefix) => line.startsWith(prefix))) { 199 const thinkBlock = lines.slice(0, index).join('\n').replace(prefixRegex, '').trim(); 200 const reply = lines.slice(index).join('\n').trim(); 201 if (thinkBlock && reply) return `<think>${thinkBlock}</think>\n${reply}`; 202 } 203 } 204 205 const withoutPrefix = trimmed.replace(prefixRegex, ''); 206 for (const prefix of replyStarts) { 207 const rx = new RegExp(`[.!?]\\s*(${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`); 208 const match = rx.exec(withoutPrefix); 209 if (match && match.index > 20) { 210 const thinkBlock = withoutPrefix.slice(0, match.index + 1).trim(); 211 const reply = withoutPrefix.slice(match.index + 1).trim(); 212 if (thinkBlock && reply) return `<think>${thinkBlock}</think>\n${reply}`; 213 } 214 } 215 216 return text; 217} 218 219/** 220 * Extract all complete thinking blocks and remaining content 221 */ 222export function extractThinkingBlocks(text) { 223 // Handle malformed patterns: <think></think>\n...actual thinking...\n</think> 224 // Some models emit an empty <think></think> then put thinking text outside, 225 // closed by a second orphaned </think>. 226 let normalized = normalizePlainThinking(text); 227 // Collapse <think>short</think>...real thinking...</think> into one block 228 // Models sometimes emit a trivial first block then continue thinking outside tags 229 normalized = normalized.replace(/<think(?:ing)?(?:\s+[^>]*)?>.{0,30}<\/think(?:ing)?>\s*([\s\S]*?)<\/think(?:ing)?>/gi, (m, content) => { 230 return '<think>' + content.trim() + '</think>'; 231 }); 232 233 // Merge consecutive <think> blocks (some models split thinking across multiple tags) 234 normalized = normalized.replace(/<\/think(?:ing)?>\s*<think(?:ing)?(?:\s+[^>]*)?>/gi, '\n\n'); 235 236 // Extract thinking time attribute if present 237 const timeMatch = normalized.match(/<think(?:ing)?\s+time="([\d.]+)"/i); 238 const thinkingTime = timeMatch ? timeMatch[1] : null; 239 // Strip time attribute for content extraction 240 normalized = normalized.replace(/<think(?:ing)?\s+time="[\d.]+"/gi, '<think'); 241 242 const thinkRegex = /<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*?)<\/think(?:ing)?>/gi; 243 const thinkingBlocks = []; 244 let match; 245 246 // Extract all complete thinking blocks 247 while ((match = thinkRegex.exec(normalized)) !== null) { 248 const content = match[1].trim(); 249 if (content) thinkingBlocks.push(content); 250 } 251 252 // Remove all complete <think>/<thinking> blocks 253 let cleanContent = normalized.replace(thinkRegex, ''); 254 255 // If there's an unclosed tag, decide between two cases: 256 // (a) Stray opener at the very start with no real reply before it â typical 257 // of quantized models (MiniMax-AWQ) that emit a literal `<think>` token 258 // at the start of every reply without ever closing it. Strip just the 259 // opener and keep the body as the reply, otherwise the bubble looks 260 // blank on reload (the body was being treated as collapsed thinking). 261 // (b) Cut-off mid-generation â there's already real reply text before the 262 // opener. Drop from the tag onward as before (it's truncated thinking). 263 if (hasUnclosedThinkTag(normalized)) { 264 const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i); 265 if (gemmaThoughtStart >= 0) { 266 const leakedThought = cleanContent 267 .slice(gemmaThoughtStart) 268 .replace(/^<\|channel>thought\s*\n?/i, '') 269 .trim(); 270 if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought); 271 cleanContent = cleanContent.slice(0, gemmaThoughtStart); 272 } else { 273 const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i); 274 if (strayOpener) { 275 cleanContent = strayOpener[1]; 276 } else { 277 cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, ''); 278 } 279 } 280 } 281 282 // Handle orphaned </think> with no opening tag â text before it is leaked thinking 283 const orphanMatch = cleanContent.match(/^([\s\S]+?)<\/think(?:ing)?>/i); 284 if (orphanMatch && orphanMatch[1].trim()) { 285 thinkingBlocks.push(orphanMatch[1].trim()); 286 cleanContent = cleanContent.slice(orphanMatch[0].length); 287 } 288 289 // Strip any remaining orphaned closing tags 290 cleanContent = cleanContent.replace(/<\/think(?:ing)?>/gi, ''); 291 292 // Merge all thinking blocks into one â no reason to show multiple dropdowns 293 const mergedBlocks = thinkingBlocks.length > 1 294 ? [thinkingBlocks.join('\n\n')] 295 : thinkingBlocks; 296 297 return { 298 thinkingBlocks: mergedBlocks, 299 content: cleanContent.trim(), 300 thinkingTime, 301 }; 302} 303 304/** 305 * Create a collapsible thinking section 306 */ 307function createThinkingSection(thinkingContent, index = 0, thinkingTime = null) { 308 const id = `thinking-${Date.now()}-${index}`; 309 const timeHtml = thinkingTime ? `<span style="font-size:11px;opacity:0.4;font-variant-numeric:tabular-nums;">${thinkingTime}s</span>` : ''; 310 return ` 311 <div class="thinking-section"> 312 <div class="thinking-header" data-thinking-id="${id}"> 313 <div class="thinking-header-left">
314 <span>View thinking process</span> 315 </div> 316 <div style="display:flex;align-items:center;gap:6px;"> 317 ${timeHtml} 318 <span class="thinking-toggle" id="${id}-toggle"></span> 319 </div> 320 </div> 321 <div class="thinking-content" id="${id}"> 322 <div class="thinking-content-inner"> 323 ${mdToHtml(thinkingContent)} 324 </div> 325 </div> 326 </div> 327 `; 328} 329 330/** 331 * Process text and render with thinking sections 332 */ 333// ââ Emoji â monochrome SVG (OpenMoji-black via same-origin /api/emoji proxy) ââ 334// Replace colorful system/Twemoji emoji with single-color line icons tinted to 335// the surrounding text color (project rule: never colorful emoji). Operates on 336// rendered HTML: only touches text outside tags and skips <code>/<pre>. 337const _EMOJI_RE = /\p{Extended_Pictographic}/u; 338const _emojiSeg = (typeof Intl !== 'undefined' && Intl.Segmenter) 339 ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; 340 341function _emojiCodepoints(emoji) { 342 // Twemoji filename rule: strip U+FE0F unless the sequence has a ZWJ (U+200D). 343 const s = emoji.indexOf('â') >= 0 ? emoji : emoji.replace(/ï¸/g, ''); 344 const cps = []; 345 for (const ch of s) { const c = ch.codePointAt(0); if (c) cps.push(c.toString(16)); } 346 return cps.join('-'); 347} 348function _emojiImg(emoji) { 349 const code = _emojiCodepoints(emoji); 350 if (!code) return emoji; 351 // Monochrome line icon: the OpenMoji black SVG is used as a CSS mask filled 352 // with the surrounding text color (currentColor), so emoji render as a single 353 // theme-tinted line glyph â never colorful (project rule). If the proxy can't 354 // supply the glyph it returns a transparent SVG, so the mask shows nothing. 355 return `<span class="emoji" role="img" aria-label="${emoji}" style="--em:url('/api/emoji/${code}.svg')"></span>`; 356} 357function _svgifyText(text) { 358 if (!_emojiSeg) return text; 359 let out = ''; 360 for (const { segment } of _emojiSeg.segment(text)) { 361 out += _EMOJI_RE.test(segment) ? _emojiImg(segment) : segment; 362 } 363 return out; 364} 365/** When "Text-only Emojis" is on, keep Unicode in HTML so deEmojify() can strip them. */ 366function _useSvgEmoji() { 367 return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis'); 368} 369 370// `opts.shortcodes` (default true) controls the issue-#345 `:name:` â emoji 371// expansion. Chat passes it through as true; document/email body renderers pass 372// false so author-typed `:shortcode:` text stays literal (see mdToHtml callers). 373// The Unicode-emoji â monochrome-SVG pass always runs regardless, so a real ð 374// in a document still renders as the themed line icon as it always has. 375export function svgifyEmoji(html, opts) { 376 if (!_useSvgEmoji() || !html) return html; 377 const allowShortcodes = !opts || opts.shortcodes !== false; 378 // Two reasons to walk the HTML: real Unicode emoji to turn into SVG icons, 379 // or `:shortcode:` text the model emitted instead of an emoji (issue #345). 380 const hasUnicode = _EMOJI_RE.test(html); 381 const hasShortcode = allowShortcodes && hasEmojiShortcode(html); 382 if (!hasUnicode && !hasShortcode) return html; 383 const parts = html.split(/(<[^>]*>)/); // odd indices = tags 384 let codeDepth = 0; 385 for (let i = 0; i < parts.length; i++) { 386 if (i % 2 === 1) { 387 const t = parts[i].toLowerCase(); 388 if (/^<(pre|code)[\s>]/.test(t)) codeDepth++; 389 else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1); 390 continue; 391 } 392 if (codeDepth !== 0) continue; 393 let seg = parts[i]; 394 // Expand shortcodes to Unicode first, then both they and any pre-existing 395 // Unicode emoji get rendered as the same monochrome line icons below. 396 if (hasShortcode) seg = replaceEmojiShortcodes(seg); 397 if (_EMOJI_RE.test(seg)) seg = _svgifyText(seg); 398 parts[i] = seg; 399 } 400 return parts.join(''); 401} 402/** 403 * Generic collapsible section that reuses the thinking-dropdown styling and its 404 * delegated toggle (any `.thinking-header[data-thinking-id]`). The label drives 405 * the "View <label>" / "Hide <label>" text via data-label. Used e.g. for the 406 * vision-model image description on a user's photo message. 407 */ 408export function createCollapsible(contentMarkdown, label = 'details') { 409 const id = `collapse-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; 410 const safeLabel = escapeHtml(label); 411 return ` 412 <div class="thinking-section"> 413 <div class="thinking-header" data-thinking-id="${id}"> 414 <div class="thinking-header-left"><span data-label="${safeLabel}">View ${safeLabel}</span></div> 415 <div style="display:flex;align-items:center;gap:6px;"><span class="thinking-toggle" id="${id}-toggle"></span></div> 416 </div> 417 <div class="thinking-content" id="${id}"><div class="thinking-content-inner">${mdToHtml(contentMarkdown)}</div></div> 418 </div>`; 419} 420 421export function processWithThinking(text) { 422 const { thinkingBlocks, content, thinkingTime } = extractThinkingBlocks(text); 423 424 let html = ''; 425 426 // Add thinking sections (collapsed by default) 427 thinkingBlocks.forEach((block, index) => { 428 html += createThinkingSection(block, index, thinkingTime); 429 }); 430 431 // Add the actual content 432 if (content) { 433 html += mdToHtml(content); 434 } 435 436 return _useSvgEmoji() ? svgifyEmoji(html) : html; 437} 438 439/** 440 * Convert markdown to HTML 441 */ 442export function mdToHtml(src, opts) { 443 const allowedHtmlBlocks = []; 444 const codeBlocks = []; 445 const mermaidBlocks = []; 446 let s = (src ?? ''); 447 448 // Extract fenced code blocks before any markdown/HTML preservation passes. 449 // Otherwise placeholders from the allowed-HTML sanitizer (e.g. 450 // ___ALLOWED_HTML_0___) can leak into quoted HTML/JS samples, because the 451 // placeholder gets captured as literal code content and never restored inside 452 // the final <pre><code> block. 453 s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => { 454 const cleaned = code 455 .replace(/\r\n/g, '\n') 456 .replace(/[ \t]+$/gm, '') 457 .replace(/^\s*\n+/, '') 458 .replace(/\n+\s*$/g, ''); 459 460 // Mermaid diagrams: render as diagram instead of code block 461 if (lang && lang.toLowerCase() === 'mermaid') { 462 const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length; 463 const raw = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); 464 const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`; 465 mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`); 466 return placeholder; 467 } 468 469 const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); 470 const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`; 471 472 const langClass = lang ? ` class="language-${lang}"` : ''; 473 const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh']; 474 const runBtn = (lang && runnableLangs.includes(lang.toLowerCase())) 475 ? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>` 476 : ''; 477 const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`; 478 codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`); 479 480 return placeholder; 481 }); 482 483 // Repair common ways the agent mangles the entity-anchor convention 484 // (`[Name](#kind-<id>)`). Models reliably get the single-link case 485 // right but slip into other formats when listing many in a table. 486 // These regexes upgrade the broken forms to proper markdown links so 487 // the standard `[text](url)` handler below picks them up. 488 const ANCHOR_KIND = '(?:session|document|note|image|email|event|task|skill|research)'; 489 // Case A: `[Name] [#kind-id]` â agent put the URL in brackets, often 490 // in a table cell next to the label. Pair them. 491 s = s.replace( 492 new RegExp(`\\[([^\\]\\n]+?)\\]\\s*\\[#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\]`, 'g'), 493 '[$1](#$2)', 494 ); 495 // Case B: bare `[#kind-id]` with no preceding label â give it a 496 // generic "â open" link text so it still renders as a button. 497 s = s.replace( 498 new RegExp(`\\[#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\]`, 'g'), 499 '[â open](#$1)', 500 ); 501 // Case C: bare `#kind-id` in plain text â only when it's word- 502 // boundary delimited and NOT already inside a markdown link or 503 // anchor syntax. Use a lookbehind for `](` or `[` to skip those. 504 s = s.replace( 505 new RegExp(`(^|[^\\[(])#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\b`, 'g'), 506 '$1[#$2](#$2)', 507 ); 508 509 // Convert markdown links [text](url) to clickable links 510 // Internal #hash links navigate in-page; external links open in new tab 511 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { 512 return linkHtml(text, url); 513 }); 514 515 // Autolink bare URLs (http/https). Skips URLs already inside <a> tags 516 // (placed by markdown link replacement above) and URLs in backticks. 517 s = s.replace( 518 /(^|[\s(<])(https?:\/\/[^\s<>"'`\]]+[^\s<>"'`\].,;:!?])/g, 519 (match, prefix, url) => `${prefix}${linkHtml(url, url)}` 520 ); 521 522 // Autolink scheme-less domains the model often emits as plain text 523 // (e.g. "techcrunch.com/ai", "perplexity.ai", "www.wired.com"). The TLD 524 // allowlist keeps it from matching file names / versions ("package.json", 525 // "node.js", "v1.2.3"); the required start/[\s(<] prefix means domains 526 // already inside an http link (preceded by "//") or an email ("@") are
527 // skipped. Require the TLD to end at a real domain boundary so dotted code 528 // identifiers like `sklearn.metrics` do not link `sklearn.me` and leave 529 // placeholder fragments in the remaining text. 530 s = s.replace( 531 /(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?=$|[\/\s<>"'`\]).,;:!?])(?:\/[^\s<>"'`\])]*)?)/gi, 532 (match, prefix, domain) => { 533 const trail = (domain.match(/[.,;:!?)]+$/) || [''])[0]; 534 const core = trail ? domain.slice(0, -trail.length) : domain; 535 return `${prefix}${linkHtml(core, 'https://' + core)}${trail}`; 536 } 537 ); 538 539 // Extract <details>...</details> blocks and replace with placeholders 540 // Default to open so agent output is visible 541 s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => { 542 const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`; 543 allowedHtmlBlocks.push(sanitizeAllowedHtml(match.replace(/<details>/i, '<details open>'))); 544 return placeholder; 545 }); 546 547 // ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion) 548 s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => { 549 const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`; 550 allowedHtmlBlocks.push(sanitizeAllowedHtml(match)); 551 return placeholder; 552 }); 553 554 // Now escape everything else 555 s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 556 557 s = s.replace(/\n{3,}/g, '\n\n'); 558 559 // KaTeX math rendering (after code blocks are extracted, so math in code is safe) 560 const mathBlocks = []; 561 if (window.katex) { 562 // Display math: \[ ... \] â GPT-style delimiter (gpt-5.x, Claude, etc.). 563 // Handle before $$/$ so all common delimiters render. 564 s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => { 565 try { 566 const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 567 const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; 568 mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false })); 569 return placeholder; 570 } catch (e) { return match; } 571 }); 572 // Inline math: \( ... \) â GPT-style inline delimiter. Single-line only 573 // ([^\n]) so a stray escaped paren in prose can't swallow across lines. 574 s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => { 575 try { 576 const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 577 const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; 578 mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false })); 579 return placeholder; 580 } catch (e) { return match; } 581 }); 582 // Display math: $$...$$ 583 s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => { 584 try { 585 const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 586 const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; 587 mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false })); 588 return placeholder; 589 } catch (e) { return match; } 590 }); 591 // Inline math: $...$ (not preceded/followed by $ or digit, not spanning multiple lines) 592 s = s.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (match, math) => { 593 try { 594 const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 595 const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; 596 mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false })); 597 return placeholder; 598 } catch (e) { return match; } 599 }); 600 } 601 602 // Handle pipe tables 603 s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => { 604 if (table.includes('___CODE_BLOCK_') || table.includes('___ALLOWED_HTML_')) return table; 605 606 const rows = table.trim().split('\n'); 607 if (rows.length < 2) return table; 608 609 let html = '<table style="border-collapse: collapse; width: 100%; margin: 10px 0;">'; 610 611 rows.forEach((row, idx) => { 612 if (idx === 1 && /^[\s|:\-]+$/.test(row)) { 613 html += '<tbody>'; 614 return; 615 } 616 const cells = splitTableRow(row); 617 if (cells.length === 0) return; 618 619 html += '<tr>'; 620
621 cells.forEach(cell => { 622 const tag = idx === 0 ? 'th' : 'td'; 623 html += `<${tag} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}</${tag}>`; 624 }); 625 626 html += '</tr>'; 627 }); 628 629 html += '</tbody></table>'; 630 return html; 631 }); 632 633 // Inline code (but not placeholders) 634 s = s.replace(/`([^`]+?)`/g, (match, code) => { 635 if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match; 636 return `<code>${code}</code>`; 637 }); 638 639 // Horizontal rules (must come before bold/italic to avoid * conflicts) 640 s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>'); 641 642 // Bold, italic, strikethrough 643 s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\*([^*]+)\*/g, '<em>$1</em>'); 644 s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>'); 645 646 // Headers 647 s = s.replace(/^###### (.*)$/gm, '<h6>$1</h6>') 648 .replace(/^##### (.*)$/gm, '<h5>$1</h5>') 649 .replace(/^#### (.*)$/gm, '<h4>$1</h4>') 650 .replace(/^### (.*)$/gm, '<h3>$1</h3>') 651 .replace(/^## (.*)$/gm, '<h2>$1</h2>') 652 .replace(/^# (.*)$/gm, '<h1>$1</h1>'); 653 654 // Ordered lists (1. 2. 3. etc.) 655 s = s.replace(/^(\d+)\. (.*)$/gm, '<oli>$2</oli>'); 656 s = s.replace(/(?:^|\n)(<oli>[\s\S]*?)(?=\n(?!<oli>)|$)/g, m => `<ol>${m.trim().replace(/<\/?oli>/g, (t) => t === '<oli>' ? '<li>' : '</li>')}</ol>`); 657 658 // GitHub-style task lists (- [ ] / - [x]) â checkbox items. Must run before 659 // the generic unordered-list rule so the "- " prefix isn't consumed first. 660 // Emits <uli> (with a class) so the unordered-list wrapper below treats it 661 // as a list item. Used by plan mode: plan + progress render as a checklist. 662 s = s.replace(/^(?:- |\* )\[([ xX])\] (.*)$/gm, (_m, mark, text) => { 663 const done = mark.toLowerCase() === 'x'; 664 return `<uli class="task-item${done ? ' task-done' : ''}"><span class="task-check" aria-hidden="true"></span><span class="task-text">${text}</span></uli>`; 665 }); 666 667 // Unordered lists. <uli> may carry attributes (task-item class), so the 668 // wrapper preserves them when converting <uli ...> â <li ...>. 669 s = s.replace(/^(?:- |\* )(.*)$/gm, '<uli>$1</uli>'); 670 s = s.replace(/(^|\n)((?:<uli\b[^>]*>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) => 671 `${prefix}<ul>${block.trim().replace(/<uli\b([^>]*)>/g, '<li$1>').replace(/<\/uli>/g, '</li>')}</ul>`); 672 673 // Blockquotes 674 s = s.replace(/^> (.*)$/gm, '<bq>$1</bq>'); 675 s = s.replace(/(?:^|\n)(<bq>[\s\S]*?)(?=\n(?!<bq>)|$)/g, m => 676 `<blockquote>${m.trim().replace(/<\/?bq>/g, (t) => t === '<bq>' ? '<p>' : '</p>')}</blockquote>`); 677 678 // Paragraphs - but NOT for code block placeholders or allowed HTML 679 s = s.replace(/^(?!<h\d|<ul>|<ol>|<li|<oli>|<\/li>|<pre>|<blockquote>|<bq>|<hr>|___CODE_BLOCK_|___ALLOWED_HTML_|___MATH_BLOCK_|___MERMAID_BLOCK_)([^\n]+)$/gm, '<p>$1</p>'); 680 681 // Line breaks within paragraphs 682 s = s.replace(/<p>([\s\S]*?)<\/p>/g, (match, content) => { 683 if (content.includes('___CODE_BLOCK_') || content.includes('___ALLOWED_HTML_') || content.includes('___MATH_BLOCK_') || content.includes('___MERMAID_BLOCK_')) return match; 684 const withLineBreaks = content.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>'); 685 return `<p>${withLineBreaks}</p>`; 686 }); 687 688 // Remove empty paragraphs 689 s = s.replace(/<p><\/p>/g, ''); 690 691 // CRITICAL: Restore allowed HTML blocks first 692 allowedHtmlBlocks.forEach((block, index) => { 693 s = s.replace(`___ALLOWED_HTML_${index}___`, block); 694 }); 695 696 // Restore math blocks 697 mathBlocks.forEach((block, index) => { 698 s = s.replace(`___MATH_BLOCK_${index}___`, block); 699 }); 700 701 // Restore mermaid diagram blocks 702 mermaidBlocks.forEach((block, index) => { 703 s = s.replace(`___MERMAID_BLOCK_${index}___`, block); 704 }); 705 706 // CRITICAL: Restore code blocks at the end 707 codeBlocks.forEach((block, index) => { 708 s = s.replace(`___CODE_BLOCK_${index}___`, block); 709 }); 710 711 return _useSvgEmoji() ? svgifyEmoji(s, opts) : s; 712} 713 714/** 715 * Reduce excessive whitespace outside of code blocks 716 */ 717export function squashOutsideCode(s) { 718 if (!s) return "";
719 const parts = String(s).split(/```/); 720 for (let i = 0; i < parts.length; i += 2) { 721 parts[i] = parts[i] 722 .replace(/\r\n/g, '\n') 723 .replace(/[ \t]+\n/g, '\n') 724 .replace(/\n{3,}/g, '\n\n'); 725 } 726 return parts.join('```'); 727} 728 729/** 730 * Render content that may be text or array of content blocks 731 */ 732export function renderContent(content) { 733 if (Array.isArray(content)) { 734 const texts = []; 735 for (const blk of content) { 736 if (blk.type === 'text') texts.push(blk.text); 737 else if (blk.type === 'image_url') texts.push('[image]'); 738 } 739 return texts.join('\n'); 740 } 741 return content; 742} 743 744/** 745 * Initialize any unprocessed Mermaid diagrams in a container (or whole document) 746 */ 747export function renderMermaid(container) { 748 if (!window.mermaid) return; 749 initMermaid(); 750 const target = container || document; 751 const pending = target.querySelectorAll('pre.mermaid:not([data-processed])'); 752 if (pending.length === 0) return; 753 try { 754 window.mermaid.run({ nodes: pending }); 755 } catch (e) { 756 console.warn('Mermaid render error:', e); 757 } 758} 759 760const markdownModule = { 761 escapeHtml, 762 mdToHtml, 763 squashOutsideCode, 764 renderContent, 765 processWithThinking, 766 createCollapsible, 767 hasUnclosedThinkTag, 768 extractThinkingBlocks, 769 normalizeThinkingMarkup, 770 startsWithReasoningPrefix, 771 renderMermaid 772}; 773 774export default markdownModule; 775 776// Mermaid is loaded async so it cannot delay the app shell. 777function initMermaid() { 778 if (!window.mermaid || window.__odysseusMermaidReady) return; 779 window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); 780 window.__odysseusMermaidReady = true; 781} 782window.odysseusInitMermaid = initMermaid; 783initMermaid(); 784 785// Persist which thinking sections were expanded across page refreshes. 786// IDs are render-generated (Date.now-based) so we key by a stable hash of 787// the inner text content instead â same content reproduces the same hash on 788// reload. LocalStorage holds a Set of expanded hashes; we observe the chat 789// history and re-expand matching sections as they're inserted. 790const THINK_EXPANDED_KEY = 'odysseus-thinking-expanded'; 791function _loadExpandedSet() { 792 try { return new Set(JSON.parse(localStorage.getItem(THINK_EXPANDED_KEY) || '[]')); } 793 catch { return new Set(); } 794} 795function _saveExpandedSet(set) { 796 try { 797 const arr = [...set]; 798 // Bound storage growth â keep the most recent 200 entries. 799 if (arr.length > 200) arr.splice(0, arr.length - 200); 800 localStorage.setItem(THINK_EXPANDED_KEY, JSON.stringify(arr)); 801 } catch {} 802} 803function _hashThinkingContent(el) { 804 if (!el) return ''; 805 const text = (el.textContent || '').trim(); 806 if (!text) return ''; 807 let h = 0; 808 for (let i = 0; i < text.length; i++) { 809 h = (h * 31 + text.charCodeAt(i)) | 0; 810 } 811 return String(h); 812} 813function _setThinkingExpanded(content, toggle, header, expanded) { 814 if (!content || !toggle) return; 815 content.classList.toggle('expanded', expanded); 816 toggle.classList.toggle('expanded', expanded); 817 const label_el = header?.querySelector('.thinking-header-left span'); 818 if (label_el) { 819 const label = label_el.dataset.label || 'thinking process'; 820 label_el.textContent = expanded ? `Hide ${label}` : `View ${label}`; 821 } 822} 823 824// Delegated click handler for thinking toggle (CSP-safe, no inline onclick) 825document.addEventListener('click', function(e) { 826 const header = e.target.closest('.thinking-header[data-thinking-id]'); 827 if (!header) return; 828 const id = header.dataset.thinkingId; 829 const content = document.getElementById(id); 830 const toggle = document.getElementById(id + '-toggle'); 831 if (!content || !toggle) return; 832 833 const willExpand = !content.classList.contains('expanded'); 834 _setThinkingExpanded(content, toggle, header, willExpand); 835 836 // Persist by content hash so the choice survives a refresh. 837 const hash = _hashThinkingContent(content); 838 if (!hash) return; 839 const set = _loadExpandedSet(); 840 if (willExpand) set.add(hash); 841 else set.delete(hash); 842 _saveExpandedSet(set); 843}); 844 845// Watch the chat history; whenever a thinking section appears, expand it if 846// its hash matches one the user previously expanded. 847(function _watchThinking() {
848 if (window._thinkingWatcherWired) return; 849 window._thinkingWatcherWired = true; 850 const _apply = (root) => { 851 if (!root || !root.querySelectorAll) return; 852 const sections = root.matches?.('.thinking-section') 853 ? [root] 854 : [...root.querySelectorAll('.thinking-section')]; 855 if (!sections.length) return; 856 const set = _loadExpandedSet(); 857 if (!set.size) return; 858 for (const sec of sections) { 859 const content = sec.querySelector('.thinking-content'); 860 if (!content) continue; 861 if (content.classList.contains('expanded')) continue; 862 const hash = _hashThinkingContent(content); 863 if (!hash || !set.has(hash)) continue; 864 const header = sec.querySelector('.thinking-header[data-thinking-id]'); 865 const id = header?.dataset.thinkingId; 866 const toggle = id ? document.getElementById(id + '-toggle') : null; 867 _setThinkingExpanded(content, toggle, header, true); 868 } 869 }; 870 const start = () => { 871 const root = document.body; 872 if (!root) return; 873 _apply(root); 874 new MutationObserver((mutations) => { 875 for (const m of mutations) { 876 for (const node of m.addedNodes) { 877 if (node.nodeType === 1) _apply(node); 878 } 879 } 880 }).observe(root, { childList: true, subtree: true }); 881 }; 882 if (document.readyState === 'loading') { 883 document.addEventListener('DOMContentLoaded', start, { once: true }); 884 } else { 885 start(); 886 } 887})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.