// static/js/markdown.js /** * Markdown rendering and content processing utilities */ import uiModule from './ui.js'; var escapeHtml = uiModule.esc; function safeLinkUrl(rawUrl) { const url = String(rawUrl || '').trim(); if (url.startsWith('#')) { return /^#[A-Za-z0-9_-]*$/.test(url) ? url : ''; } try { const parsed = new URL(url, window.location.origin); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { return parsed.href; } } catch (_) { return ''; } return ''; } function linkHtml(text, url) { const safeUrl = safeLinkUrl(url); const safeText = escapeHtml(text); if (!safeUrl) return safeText; if (safeUrl.startsWith('#')) { return `${safeText}`; } return `${safeText}`; } /** * Check if text has unclosed think tag */ export function hasUnclosedThinkTag(text) { const openCount = (text.match(//gi) || []).length; const closeCount = (text.match(/<\/think(?:ing)?>/gi) || []).length; return openCount > closeCount; } export function startsWithReasoningPrefix(text) { return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || ''); } function normalizePlainThinking(text) { if (!text || / value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const boundaryRegex = new RegExp( `^([\\s\\S]*?)(\\n\\n(?=${escapedReplyStarts.join('|')}|I |What|Let|This |As ))[\\s\\S]*$`, 'i' ); const boundaryMatch = boundaryRegex.exec(trimmed); if (boundaryMatch) { const thinkBlock = boundaryMatch[1].replace(prefixRegex, '').trim(); const reply = trimmed.slice(boundaryMatch[1].length).trimStart(); if (thinkBlock && reply) return `${thinkBlock}\n\n${reply}`; } const lines = trimmed.split('\n'); for (let index = 1; index < lines.length; index += 1) { const line = lines[index].trim(); if (!line) continue; if (replyStarts.some((prefix) => line.startsWith(prefix))) { const thinkBlock = lines.slice(0, index).join('\n').replace(prefixRegex, '').trim(); const reply = lines.slice(index).join('\n').trim(); if (thinkBlock && reply) return `${thinkBlock}\n${reply}`; } } const withoutPrefix = trimmed.replace(prefixRegex, ''); for (const prefix of replyStarts) { const rx = new RegExp(`[.!?]\\s*(${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`); const match = rx.exec(withoutPrefix); if (match && match.index > 20) { const thinkBlock = withoutPrefix.slice(0, match.index + 1).trim(); const reply = withoutPrefix.slice(match.index + 1).trim(); if (thinkBlock && reply) return `${thinkBlock}\n${reply}`; } } return text; } /** * Extract all complete thinking blocks and remaining content */ export function extractThinkingBlocks(text) { // Handle malformed patterns: \n...actual thinking...\n // Some models emit an empty then put thinking text outside, // closed by a second orphaned . let normalized = normalizePlainThinking(text); // Collapse short...real thinking... into one block // Models sometimes emit a trivial first block then continue thinking outside tags normalized = normalized.replace(/]*)?>.{0,30}<\/think(?:ing)?>\s*([\s\S]*?)<\/think(?:ing)?>/gi, (m, content) => { return '' + content.trim() + ''; }); // Merge consecutive blocks (some models split thinking across multiple tags) normalized = normalized.replace(/<\/think(?:ing)?>\s*]*)?>/gi, '\n\n'); // Extract thinking time attribute if present const timeMatch = normalized.match(/]*)?>([\s\S]*?)<\/think(?:ing)?>/gi; const thinkingBlocks = []; let match; // Extract all complete thinking blocks while ((match = thinkRegex.exec(normalized)) !== null) { const content = match[1].trim(); if (content) thinkingBlocks.push(content); } // Remove all complete / blocks let cleanContent = normalized.replace(thinkRegex, ''); // If there's an unclosed tag, decide between two cases: // (a) Stray opener at the very start with no real reply before it — typical // of quantized models (MiniMax-AWQ) that emit a literal `` token // at the start of every reply without ever closing it. Strip just the // opener and keep the body as the reply, otherwise the bubble looks // blank on reload (the body was being treated as collapsed thinking). // (b) Cut-off mid-generation — there's already real reply text before the // opener. Drop from the tag onward as before (it's truncated thinking). if (hasUnclosedThinkTag(normalized)) { const strayOpener = cleanContent.match(/^\s*]*)?>([\s\S]*)$/i); if (strayOpener) { cleanContent = strayOpener[1]; } else { cleanContent = cleanContent.replace(/]*)?>[\s\S]*$/gi, ''); } } // Handle orphaned with no opening tag — text before it is leaked thinking const orphanMatch = cleanContent.match(/^([\s\S]+?)<\/think(?:ing)?>/i); if (orphanMatch && orphanMatch[1].trim()) { thinkingBlocks.push(orphanMatch[1].trim()); cleanContent = cleanContent.slice(orphanMatch[0].length); } // Strip any remaining orphaned closing tags cleanContent = cleanContent.replace(/<\/think(?:ing)?>/gi, ''); // Merge all thinking blocks into one — no reason to show multiple dropdowns const mergedBlocks = thinkingBlocks.length > 1 ? [thinkingBlocks.join('\n\n')] : thinkingBlocks; return { thinkingBlocks: mergedBlocks, content: cleanContent.trim(), thinkingTime, }; } /** * Create a collapsible thinking section */ function createThinkingSection(thinkingContent, index = 0, thinkingTime = null) { const id = `thinking-${Date.now()}-${index}`; const timeHtml = thinkingTime ? `${thinkingTime}s` : ''; return `
View thinking process
${timeHtml}
${mdToHtml(thinkingContent)}
`; } /** * Process text and render with thinking sections */ // ── Emoji → monochrome SVG (OpenMoji-black via same-origin /api/emoji proxy) ── // Replace colorful system/Twemoji emoji with single-color line icons tinted to // the surrounding text color (project rule: never colorful emoji). Operates on // rendered HTML: only touches text outside tags and skips /
.
const _EMOJI_RE = /\p{Extended_Pictographic}/u;
const _emojiSeg = (typeof Intl !== 'undefined' && Intl.Segmenter)
  ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;

function _emojiCodepoints(emoji) {
  // Twemoji filename rule: strip U+FE0F unless the sequence has a ZWJ (U+200D).
  const s = emoji.indexOf('‍') >= 0 ? emoji : emoji.replace(/️/g, '');
  const cps = [];
  for (const ch of s) { const c = ch.codePointAt(0); if (c) cps.push(c.toString(16)); }
  return cps.join('-');
}
function _emojiImg(emoji) {
  const code = _emojiCodepoints(emoji);
  if (!code) return emoji;
  // Monochrome line icon: the OpenMoji black SVG is used as a CSS mask filled
  // with the surrounding text color (currentColor), so emoji render as a single
  // theme-tinted line glyph — never colorful (project rule). If the proxy can't
  // supply the glyph it returns a transparent SVG, so the mask shows nothing.
  return ``;
}
function _svgifyText(text) {
  if (!_emojiSeg) return text;
  let out = '';
  for (const { segment } of _emojiSeg.segment(text)) {
    out += _EMOJI_RE.test(segment) ? _emojiImg(segment) : segment;
  }
  return out;
}
/** When "Text-only Emojis" is on, keep Unicode in HTML so deEmojify() can strip them. */
function _useSvgEmoji() {
  return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis');
}

export function svgifyEmoji(html) {
  if (!_useSvgEmoji() || !html || !_EMOJI_RE.test(html)) return html;
  const parts = html.split(/(<[^>]*>)/);   // odd indices = tags
  let codeDepth = 0;
  for (let i = 0; i < parts.length; i++) {
    if (i % 2 === 1) {
      const t = parts[i].toLowerCase();
      if (/^<(pre|code)[\s>]/.test(t)) codeDepth++;
      else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1);
      continue;
    }
    if (codeDepth === 0 && _EMOJI_RE.test(parts[i])) parts[i] = _svgifyText(parts[i]);
  }
  return parts.join('');
}
/**
 * Generic collapsible section that reuses the thinking-dropdown styling and its
 * delegated toggle (any `.thinking-header[data-thinking-id]`). The label drives
 * the "View 
([\s\S]*?)<\/details>/gi, (match) => { const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`; allowedHtmlBlocks.push(match.replace(/
/i, '
')); return placeholder; }); // ALSO preserve tags the same way (they're now in the HTML from markdown conversion) s = s.replace(/]*>.*?<\/a>/gi, (match) => { const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`; allowedHtmlBlocks.push(match); return placeholder; }); // Now escape everything else s = s.replace(/&/g, '&').replace(//g, '>'); s = s.replace(/\n{3,}/g, '\n\n'); // CRITICAL: Extract code blocks and replace with placeholders const codeBlocks = []; const mermaidBlocks = []; s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => { const cleaned = code .replace(/\r\n/g, '\n') .replace(/[ \t]+$/gm, '') .replace(/^\s*\n+/, '') .replace(/\n+\s*$/g, ''); // Mermaid diagrams: render as diagram instead of code block if (lang && lang.toLowerCase() === 'mermaid') { const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length; const raw = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`; mermaidBlocks.push(`
${escapeHtml(raw)}
`); return placeholder; } const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`; const langClass = lang ? ` class="language-${lang}"` : ''; const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh']; const runBtn = (lang && runnableLangs.includes(lang.toLowerCase())) ? `` : ''; const editBtn = ``; codeBlocks.push(`
${escapeHtml(escaped)}${runBtn}${editBtn}
`); return placeholder; }); // KaTeX math rendering (after code blocks are extracted, so math in code is safe) const mathBlocks = []; if (window.katex) { // Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.). // Handle before $$/$ so all common delimiters render. s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => { try { const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false })); return placeholder; } catch (e) { return match; } }); // Inline math: \( ... \) — GPT-style inline delimiter. Single-line only // ([^\n]) so a stray escaped paren in prose can't swallow across lines. s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => { try { const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false })); return placeholder; } catch (e) { return match; } }); // Display math: $$...$$ s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => { try { const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false })); return placeholder; } catch (e) { return match; } }); // Inline math: $...$ (not preceded/followed by $ or digit, not spanning multiple lines) s = s.replace(/(? { try { const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false })); return placeholder; } catch (e) { return match; } }); } // Handle pipe tables s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => { if (table.includes('___CODE_BLOCK_') || table.includes('___ALLOWED_HTML_')) return table; const rows = table.trim().split('\n'); if (rows.length < 2) return table; let html = ''; rows.forEach((row, idx) => { const cells = row.split('|').filter(cell => cell.trim() !== ''); if (cells.length === 0) return; html += idx === 1 ? '' : ''; html += ''; cells.forEach(cell => { const tag = idx === 0 ? 'th' : 'td'; const style = idx === 1 ? 'style="border-top: 2px solid var(--red);"' : ''; html += `<${tag} ${style} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}`; }); html += ''; }); html += '
'; return html; }); // Inline code (but not placeholders) s = s.replace(/`([^`]+?)`/g, (match, code) => { if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match; return `${code}`; }); // Horizontal rules (must come before bold/italic to avoid * conflicts) s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '
'); // Bold, italic, strikethrough s = s.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1'); s = s.replace(/~~([^~]+)~~/g, '$1'); // Headers s = s.replace(/^###### (.*)$/gm, '
$1
') .replace(/^##### (.*)$/gm, '
$1
') .replace(/^#### (.*)$/gm, '

$1

') .replace(/^### (.*)$/gm, '

$1

') .replace(/^## (.*)$/gm, '

$1

') .replace(/^# (.*)$/gm, '

$1

'); // Ordered lists (1. 2. 3. etc.) s = s.replace(/^(\d+)\. (.*)$/gm, '$2'); s = s.replace(/(?:^|\n)([\s\S]*?)(?=\n(?!)|$)/g, m => `
    ${m.trim().replace(/<\/?oli>/g, (t) => t === '' ? '
  1. ' : '
  2. ')}
`); // Unordered lists s = s.replace(/^(?:- |\* )(.*)$/gm, '
  • $1
  • '); s = s.replace(/(?:^|\n)(
  • [\s\S]*?)(?=\n(?!
  • )|$)/g, m => `
      ${m.trim()}
    `); // Blockquotes s = s.replace(/^> (.*)$/gm, '$1'); s = s.replace(/(?:^|\n)([\s\S]*?)(?=\n(?!)|$)/g, m => `
    ${m.trim().replace(/<\/?bq>/g, (t) => t === '' ? '

    ' : '

    ')}
    `); // Paragraphs - but NOT for code block placeholders or allowed HTML s = s.replace(/^(?!|
      |
    1. ||
      |
      ||
      |___CODE_BLOCK_|___ALLOWED_HTML_|___MATH_BLOCK_|___MERMAID_BLOCK_)([^\n]+)$/gm, '

      $1

      '); // Line breaks within paragraphs s = s.replace(/

      ([\s\S]*?)<\/p>/g, (match, content) => { if (content.includes('___CODE_BLOCK_') || content.includes('___ALLOWED_HTML_') || content.includes('___MATH_BLOCK_') || content.includes('___MERMAID_BLOCK_')) return match; const withLineBreaks = content.replace(/\n{2,}/g, '

      ').replace(/\n/g, '
      '); return `

      ${withLineBreaks}

      `; }); // Remove empty paragraphs s = s.replace(/

      <\/p>/g, ''); // CRITICAL: Restore allowed HTML blocks first allowedHtmlBlocks.forEach((block, index) => { s = s.replace(`___ALLOWED_HTML_${index}___`, block); }); // Restore math blocks mathBlocks.forEach((block, index) => { s = s.replace(`___MATH_BLOCK_${index}___`, block); }); // Restore mermaid diagram blocks mermaidBlocks.forEach((block, index) => { s = s.replace(`___MERMAID_BLOCK_${index}___`, block); }); // CRITICAL: Restore code blocks at the end codeBlocks.forEach((block, index) => { s = s.replace(`___CODE_BLOCK_${index}___`, block); }); return _useSvgEmoji() ? svgifyEmoji(s) : s; } /** * Reduce excessive whitespace outside of code blocks */ export function squashOutsideCode(s) { if (!s) return ""; const parts = String(s).split(/```/); for (let i = 0; i < parts.length; i += 2) { parts[i] = parts[i] .replace(/\r\n/g, '\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n'); } return parts.join('```'); } /** * Render content that may be text or array of content blocks */ export function renderContent(content) { if (Array.isArray(content)) { const texts = []; for (const blk of content) { if (blk.type === 'text') texts.push(blk.text); else if (blk.type === 'image_url') texts.push('[image]'); } return texts.join('\n'); } return content; } /** * Initialize any unprocessed Mermaid diagrams in a container (or whole document) */ export function renderMermaid(container) { if (!window.mermaid) return; initMermaid(); const target = container || document; const pending = target.querySelectorAll('pre.mermaid:not([data-processed])'); if (pending.length === 0) return; try { window.mermaid.run({ nodes: pending }); } catch (e) { console.warn('Mermaid render error:', e); } } const markdownModule = { escapeHtml, mdToHtml, squashOutsideCode, renderContent, processWithThinking, createCollapsible, hasUnclosedThinkTag, extractThinkingBlocks, startsWithReasoningPrefix, renderMermaid }; export default markdownModule; // Mermaid is loaded async so it cannot delay the app shell. function initMermaid() { if (!window.mermaid || window.__odysseusMermaidReady) return; window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); window.__odysseusMermaidReady = true; } window.odysseusInitMermaid = initMermaid; initMermaid(); // Persist which thinking sections were expanded across page refreshes. // IDs are render-generated (Date.now-based) so we key by a stable hash of // the inner text content instead — same content reproduces the same hash on // reload. LocalStorage holds a Set of expanded hashes; we observe the chat // history and re-expand matching sections as they're inserted. const THINK_EXPANDED_KEY = 'odysseus-thinking-expanded'; function _loadExpandedSet() { try { return new Set(JSON.parse(localStorage.getItem(THINK_EXPANDED_KEY) || '[]')); } catch { return new Set(); } } function _saveExpandedSet(set) { try { const arr = [...set]; // Bound storage growth — keep the most recent 200 entries. if (arr.length > 200) arr.splice(0, arr.length - 200); localStorage.setItem(THINK_EXPANDED_KEY, JSON.stringify(arr)); } catch {} } function _hashThinkingContent(el) { if (!el) return ''; const text = (el.textContent || '').trim(); if (!text) return ''; let h = 0; for (let i = 0; i < text.length; i++) { h = (h * 31 + text.charCodeAt(i)) | 0; } return String(h); } function _setThinkingExpanded(content, toggle, header, expanded) { if (!content || !toggle) return; content.classList.toggle('expanded', expanded); toggle.classList.toggle('expanded', expanded); const label_el = header?.querySelector('.thinking-header-left span'); if (label_el) { const label = label_el.dataset.label || 'thinking process'; label_el.textContent = expanded ? `Hide ${label}` : `View ${label}`; } } // Delegated click handler for thinking toggle (CSP-safe, no inline onclick) document.addEventListener('click', function(e) { const header = e.target.closest('.thinking-header[data-thinking-id]'); if (!header) return; const id = header.dataset.thinkingId; const content = document.getElementById(id); const toggle = document.getElementById(id + '-toggle'); if (!content || !toggle) return; const willExpand = !content.classList.contains('expanded'); _setThinkingExpanded(content, toggle, header, willExpand); // Persist by content hash so the choice survives a refresh. const hash = _hashThinkingContent(content); if (!hash) return; const set = _loadExpandedSet(); if (willExpand) set.add(hash); else set.delete(hash); _saveExpandedSet(set); }); // Watch the chat history; whenever a thinking section appears, expand it if // its hash matches one the user previously expanded. (function _watchThinking() { if (window._thinkingWatcherWired) return; window._thinkingWatcherWired = true; const _apply = (root) => { if (!root || !root.querySelectorAll) return; const sections = root.matches?.('.thinking-section') ? [root] : [...root.querySelectorAll('.thinking-section')]; if (!sections.length) return; const set = _loadExpandedSet(); if (!set.size) return; for (const sec of sections) { const content = sec.querySelector('.thinking-content'); if (!content) continue; if (content.classList.contains('expanded')) continue; const hash = _hashThinkingContent(content); if (!hash || !set.has(hash)) continue; const header = sec.querySelector('.thinking-header[data-thinking-id]'); const id = header?.dataset.thinkingId; const toggle = id ? document.getElementById(id + '-toggle') : null; _setThinkingExpanded(content, toggle, header, true); } }; const start = () => { const root = document.body; if (!root) return; _apply(root); new MutationObserver((mutations) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === 1) _apply(node); } } }).observe(root, { childList: true, subtree: true }); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start, { once: true }); } else { start(); } })();