diff --git a/src/App.jsx b/src/App.jsx index 8200241..5d77b9d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -111,32 +111,27 @@ export default function App() { const [editingMessageIndex, setEditingMessageIndex] = useState(null); const [editText, setEditText] = useState(''); // Helpers + handlers for message copy/edit/regenerate (must live inside App) - function getVisibleTextForCopy(message) { + function getMarkdownForCopy(message) { const raw = message.content || ''; - if (message.role !== 'assistant') { - // For user messages, copy exactly what they typed. - return raw; - } - // Assistant messages: strip think, render as HTML, then extract readable text. - let shown = raw; - try { - const { think, answer } = splitThinkBlocks(raw); - shown = answer || raw; - } catch (_) {} - const html = markdownToHTML(shown); - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - tempDiv.querySelectorAll('br').forEach(br => br.replaceWith('\n')); - tempDiv.querySelectorAll('p,div,li,pre,code,h1,h2,h3,h4,h5,h6,table,tr').forEach(el => { - el.insertAdjacentText('beforeend', '\n'); - }); - return tempDiv.innerText.replace(/\n{3,}/g, '\n\n').trim(); + if (message.role === 'assistant') { + // Copy the assistant's raw *markdown answer*, not rendered text, + // and strip any ... block. + try { + const { answer } = splitThinkBlocks(raw); + return (answer || raw).trim(); + } catch { + return raw.trim(); + } + } + + // User messages: copy exactly as typed + return raw; } async function handleCopyMessage(message) { try { - await navigator.clipboard.writeText(getVisibleTextForCopy(message)); + await navigator.clipboard.writeText(getMarkdownForCopy(message)); } catch (err) { console.error('Failed to copy message:', err); } diff --git a/src/markdown.js b/src/markdown.js index a4d630c..2183423 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -5,6 +5,8 @@ export function markdownToHTML(text) { // or tag is found, OR the end of the string ($). text = text.replace(/[\s\S]*?(?:<\/think(?:ing)?>|$)/gi, ''); + text = balanceStreamingCodeFence(text); + // 1) Normalize line endings let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); @@ -164,4 +166,45 @@ export function markdownToHTML(text) { .replace(/(
]*>[\s\S]*?<\/div>)\s*
/g, "$1"); //
right after closing return html; +} + +// Virtually close an unfinished fenced code block so it renders during streaming. +function balanceStreamingCodeFence(md) { + // Split into lines; we only consider fences that start a line. + const lines = md.split(/\r?\n/); + + // Track the last unmatched opening fence we see while scanning. + // { fenceChar: '`' or '~', fenceLen: number } + let open = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Opening fence? ^\s*([`~]{3,})(.*)$ + if (!open) { + const m = line.match(/^\s*([`~]{3,})([^\s]*)?.*$/); + if (m) { + // Treat as an opening fence + open = { fenceChar: m[1][0], fenceLen: m[1].length }; + continue; + } + } else { + // Closing fence: must match same char and length or longer + const re = new RegExp(`^\\s*(${open.fenceChar}{${open.fenceLen},})\\s*$`); + if (re.test(line)) { + // Closed + open = null; + continue; + } + // Otherwise still inside the code block; keep scanning + } + } + + if (open) { + // Virtually close with the same fence so the block renders now + const virtual = `${open.fenceChar.repeat(open.fenceLen)}`; + return md.endsWith('\n') ? md + virtual : md + '\n' + virtual; + } + + return md; } \ No newline at end of file