|
|___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();
}
})();