Improved codeblock-markdown-parser for a better experiencing when the LLM responds with code in Stream-mode

This commit is contained in:
2025-08-26 05:32:45 +02:00
parent eb753582c1
commit e262e4b4fe
2 changed files with 58 additions and 20 deletions

View File

@@ -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 <think>...</think> 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);
}

View File

@@ -5,6 +5,8 @@ export function markdownToHTML(text) {
// </think> or </thinking> tag is found, OR the end of the string ($).
text = text.replace(/<think(?:ing)?>[\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(/(<div class="codeblock"[^>]*>[\s\S]*?<\/div>)\s*<br>/g, "$1"); // <br> 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;
}