Add streaming unread logic, copy button, and improved Markdown rendering

Implemented robust streaming handling for assistant replies: a placeholder message is inserted, unread sessions are flagged when the user is in a different chat, and scroll‑to‑bottom/ tip logic is applied once the stream completes.
  • Added copy‑to‑clipboard support for code blocks with visual feedback.
  • Re‑implemented Markdown to clean trailing whitespace in code blocks, preserve formatting, add copy buttons, and refine table & blockquote handling.
  • Introduced new CSS classes (.codeblock, .codeblock__header, .codeblock__copy, etc.) to style the code block wrapper, header bar, copy button, and pre/code area.
  • Updated the front‑end to wire up the new copy handler, manage pending scroll targets, and keep unread state in sync.
  • Minor cleanup of stray  tags and comments throughout the renderer.
  • Minor fixes to ensure proper scrolling behavior when the user scrolls away from the bottom during streaming.
  • Added descriptive comments and ensured cross‑browser copy functionality via navigator.clipboard.
  • Updated the markdown renderer to keep raw newlines for copy‑paste fidelity and to escape HTML entities correctly.
  • Adjusted CSS to match existing theme variables and provide smooth copy button transitions.
This commit is contained in:
2025-08-26 02:56:56 +02:00
parent 08d27f0007
commit 7b4a699e2d
3 changed files with 203 additions and 60 deletions

View File

@@ -224,12 +224,14 @@ export default function App() {
if (streamOutput) {
const assistantMsgId = `msg-${Date.now()}-${Math.random()}`;
// add placeholder assistant message
setChatSessions(prev =>
prev.map(s => s.session_id === sessionId
? { ...s, messages: [...(s.messages || []), { id: assistantMsgId, role: 'assistant', content: '' }] }
: s
)
);
try {
const res = await fetch(`${ollamaApiUrl}/sessions/${sessionId}/regenerate`, {
method: 'POST',
@@ -239,17 +241,43 @@ export default function App() {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
let unreadMarked = false; // NEW
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
full += chunk;
// Update the growing assistant message
setChatSessions(prev =>
prev.map(s => s.session_id === sessionId
? { ...s, messages: (s.messages || []).map(m => m.id === assistantMsgId ? { ...m, content: full } : m) }
: s
)
);
// If this session is not active while streaming, mark unread once
if (!unreadMarked && activeSessionIdRef.current !== sessionId) {
unreadMarked = true;
setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId }));
setUnreadSessions(prev => [...new Set([...prev, sessionId])]);
}
}
// On stream end: if user is in another chat, ensure unread + guided scroll are set
if (activeSessionIdRef.current !== sessionId) {
setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId }));
setUnreadSessions(prev => [...new Set([...prev, sessionId])]);
} else {
// If user stayed here and didn't scroll up, align the finished answer nicely
if (!userScrolledUpRef.current[sessionId]) {
requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId));
} else {
// show the tip if they had scrolled away
setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId }));
}
}
} catch (e) {
console.error(e);
@@ -264,12 +292,26 @@ export default function App() {
body: JSON.stringify({ index, model, stream: false })
});
const data = await res.json();
const assistantMsgId = `msg-${Date.now()}`;
setChatSessions(prev =>
prev.map(s => s.session_id === sessionId
? { ...s, messages: [...(s.messages || []), { role: 'assistant', content: data.reply, id: `msg-${Date.now()}` }] }
? { ...s, messages: [...(s.messages || []), { role: 'assistant', content: data.reply, id: assistantMsgId }] }
: s
)
);
if (activeSessionIdRef.current !== sessionId) {
// reply landed in background -> mark unread + remember where to scroll
setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId }));
setUnreadSessions(prev => [...new Set([...prev, sessionId])]);
} else {
// same chat -> align unless the user scrolled away
if (!userScrolledUpRef.current[sessionId]) {
requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId));
} else {
setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId }));
}
}
} catch (e) {
console.error(e);
} finally {
@@ -349,6 +391,31 @@ export default function App() {
}
}, [isResizing]);
React.useEffect(() => {
const onClick = async (e) => {
const btn = e.target.closest('.codeblock__copy');
if (!btn) return;
const wrapper = btn.closest('.codeblock');
const codeEl = wrapper?.querySelector('pre > code');
if (!codeEl) return;
try {
// Use textContent to copy the plain code accurately
await navigator.clipboard.writeText(codeEl.textContent || '');
// Optional: brief visual feedback
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 800);
} catch (err) {
console.error('Copy failed:', err);
}
};
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
}, []);
// Load settings on startup
useEffect(() => {
window.electronAPI.getSettings().then(settings => {

View File

@@ -8,15 +8,20 @@ export function markdownToHTML(text) {
// 1) Normalize line endings
let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// 2) Extract code blocks and replace with placeholders
// 2) Extract code blocks and replace with placeholders (protect from all formatting)
const codeblocks = [];
const placeholder = idx => `@@CODEBLOCK${idx}@@`;
tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
codeblocks.push({ lang: (lang || '').trim(), code });
// Strip trailing whitespace-only lines at the end of the block
let cleaned = (code || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = cleaned.split('\n');
while (lines.length > 0 && /^\s*$/.test(lines[lines.length - 1])) lines.pop();
cleaned = lines.join('\n');
codeblocks.push({ lang: (lang || '').trim(), code: cleaned });
return placeholder(codeblocks.length - 1);
});
// 3) HTML-escape special characters
// 3) HTML-escape special characters (outside of code blocks)
let escaped = tmp
.replace(/&/g, "&")
.replace(/</g, "<")
@@ -61,49 +66,49 @@ export function markdownToHTML(text) {
);
// 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols.
const mdTableBlockRe =
/(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
const mdTableBlockRe =
/(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
escaped = escaped.replace(mdTableBlockRe, (block) => {
const hadTrailingNewline = /\n$/.test(block);
const lines = block.replace(/\n$/, '').split('\n');
escaped = escaped.replace(mdTableBlockRe, (block) => {
const hadTrailingNewline = /\n$/.test(block);
const lines = block.replace(/\n$/, '').split('\n');
const split = (line) => line.replace(/^\||\|$/g, '').split('|').map(s => s.trim());
const split = (line) => line.replace(/^\||\|$/g, '').split('|').map(s => s.trim());
const headers = split(lines[0]);
const seps = split(lines[1]);
if (headers.length < 2 || seps.length < 2) return block;
if (!seps.every(s => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
const headers = split(lines[0]);
const seps = split(lines[1]);
if (headers.length < 2 || seps.length < 2) return block;
if (!seps.every(s => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
const aligns = seps.map(seg => {
const s = seg.replace(/\s+/g,'');
const left = s.startsWith(':');
const right = s.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
return 'left';
const aligns = seps.map(seg => {
const s = seg.replace(/\s+/g,'');
const left = s.startsWith(':');
const right = s.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
return 'left';
});
const bodyLines = lines.slice(2).filter(l => /^\|/.test(l.trim()));
const cellStyle = (i) =>
` style="text-align:${aligns[i] || 'left'};vertical-align:top;padding:.6rem .75rem"`;
const ths = headers.map((h,i)=>`<th${cellStyle(i)}>${h}</th>`).join('');
const rows = bodyLines.map(line => {
const cells = split(line);
const tds = cells.map((c,i)=>`<td${cellStyle(i)}>${c}</td>`).join('');
return `<tr>${tds}</tr>`;
}).join('');
const table = `<table class="nice" style="border-collapse:separate;border-spacing:0;width:100%;margin:1rem 0"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
return table + (hadTrailingNewline ? '\n' : '');
});
const bodyLines = lines.slice(2).filter(l => /^\|/.test(l.trim()));
const cellStyle = (i) =>
` style="text-align:${aligns[i] || 'left'};vertical-align:top;padding:.6rem .75rem"`;
const ths = headers.map((h,i)=>`<th${cellStyle(i)}>${h}</th>`).join('');
const rows = bodyLines.map(line => {
const cells = split(line);
const tds = cells.map((c,i)=>`<td${cellStyle(i)}>${c}</td>`).join('');
return `<tr>${tds}</tr>`;
}).join('');
const table = `<table class="nice" style="border-collapse:separate;border-spacing:0;width:100%;margin:1rem 0"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
return table + (hadTrailingNewline ? '\n' : '');
});
// 4.75) Horizontal rules
escaped = escaped.replace(/^---\s*$/gm, "<hr>");
// 5) Bold, italic, inline code
// 5) Bold, italic, inline code (inline code only; fenced were extracted)
let html = escaped
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<i>$1</i>")
@@ -112,38 +117,51 @@ escaped = escaped.replace(mdTableBlockRe, (block) => {
// 5.5) Links
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, '<a href="$2" target="_blank"><span>$1</span> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg><span class="tooltip">$2</span></a>');
// 6) Restore code blocks with title bar (language)
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
const { lang, code } = codeblocks[+idx];
const title = (lang && lang.trim()) ? lang.trim() : 'code';
const escapedCode = code.replace(/</g, "<").replace(/>/g, ">");
const head = `<div class="md-code-head" style="background:var(--panel);border-bottom:1px solid var(--panel);padding:.6rem .75rem;font-weight:600">${title}</div>`;
const body = `<pre style="margin:0;padding:.75rem;border:0;overflow:auto"><code class="language-${title}">${escapedCode}</code></pre>`;
return `<div class="md-code" style="margin:1rem 0;border:1px solid var(--border);border-radius:12px;overflow:hidden">${head}${body}</div>`;
});
// 7) Convert line-breaks to <br>
// 6) Convert line-breaks to <br> for NON-code content (code is still placeholdered)
html = html.replace(/\n/g, "<br>");
// 8) Cleanup stray <br> around lists/tables/wrappers
// 6.1) Cleanup stray <br> around lists/tables/wrappers that already exist
html = html
.replace(/<br>\s*(<ul>)/g, "$1")
.replace(/(<\/ul>)\s*<br>/g, "$1")
.replace(/<br>\s*(<div class="md-table"[^>]*>)/g, "$1")
.replace(/(<\/div>)\s*<br>/g, "$1")
.replace(/<br>\s*(<div class="md-code"[^>]*>)/g, "$1")
.replace(/(<\/div>)\s*<br>/g, "$1")
.replace(/<br>\s*(<table\b[^>]*>)/g, "$1")
.replace(/(<\/table>)\s*<br>/g, "$1")
.replace(/<br>\s*(<blockquote>)/g, "$1") // New: Cleanup <br> before blockquote
.replace(/(<\/blockquote>)\s*<br>/g, "$1"); // New: Cleanup <br> after blockquote
.replace(/<br>\s*(<blockquote>)/g, "$1")
.replace(/(<\/blockquote>)\s*<br>/g, "$1");
// 9) Trim spaces/tabs and remove empty newline(s) immediately after <hr>, blockquote, and ul
// 6.2) Trim spaces/tabs and remove empty newline(s) after <hr>, blockquote, ul
html = html
.replace(/(<hr>)[ \t]+/g, "$1") // remove spaces/tabs
.replace(/(<hr>)(?:[ \t]*<br>)+/g, "$1") // remove one or more blank lines (now <br>) after <hr>
.replace(/(<\/blockquote>)(?:[ \t]*<br>)+/g, "$1") // New: Remove empty lines after blockquote
.replace(/(<\/ul>)(?:[ \t]*<br>)+/g, "$1"); // New: Remove empty lines after ul
.replace(/(<hr>)[ \t]+/g, "$1")
.replace(/(<hr>)(?:[ \t]*<br>)+/g, "$1")
.replace(/(<\/blockquote>)(?:[ \t]*<br>)+/g, "$1")
.replace(/(<\/ul>)(?:[ \t]*<br>)+/g, "$1");
// 7) Restore code blocks with title bar (language) + copy button (no inline handlers)
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
const { lang, code } = codeblocks[+idx];
const title = (lang && lang.trim()) ? lang.trim() : 'code';
// Escape only for HTML rendering inside <code>; keep raw \n (no <br> here!)
const escapedCode = code
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
// Single-line header to avoid global <br> interference
const head = `<div class="codeblock__header"><div class="codeblock__lang">${title}</div><button type="button" class="codeblock__copy" aria-label="Copy code" title="Copy code"><svg class="icon icon-copy" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg></button></div>`;
// Ensure wrapping inside container and preserve newlines for copy/paste
const body = `<pre class="codeblock__pre" style="margin:0;padding:.75rem;border:0;overflow:auto;max-width:100%"><code class="codeblock__code language-${title}" style="display:block;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;max-width:100%">${escapedCode}</code></pre>`;
return `<div class="codeblock" style="margin:1rem 0;border:1px solid var(--border);border-radius:12px;overflow:hidden">${head}${body}</div>`;
});
// 8) Final cleanup around codeblocks specifically (remove stray <br> added next to placeholders)
html = html
.replace(/<br>\s*(?=<div class="codeblock"\b)/g, "") // <br> before opening
.replace(/(<div class="codeblock"[^>]*>[\s\S]*?<\/div>)\s*<br>/g, "$1"); // <br> right after closing
return html;
}
}

View File

@@ -699,6 +699,64 @@ input:checked + .slider:before {
}
/* Codeblock wrapper */
.codeblock {
border: 1px solid var(--grid, #e5e7eb);
border-radius: 12px;
overflow: hidden;
margin: 1rem 0;
}
/* Header bar (mirrors your <th> look) */
.codeblock__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem .75rem;
background: var(--panel);
border-bottom: 1px solid var(--grid);
font-weight: 600;
font-size: 0.875rem;
}
/* Language badge */
.codeblock__lang {
opacity: 0.9;
}
/* Copy button */
.codeblock__copy {
display: inline-flex;
align-items: center;
gap: .25rem;
border: 0;
background: transparent;
padding: .25rem;
border-radius: 8px;
cursor: pointer;
}
.codeblock__copy:hover {
background: rgba(0,0,0,0.06);
}
.codeblock__copy.copied .icon-copy {
transform: scale(1.05);
}
/* Pre/code styling inside wrapper (inherit your existing code styles) */
.codeblock__pre {
margin: 0;
padding: .75rem;
overflow: auto;
}
.codeblock__code {
display: block;
white-space: pre;
}
/* Assistant reasoning (thoughts) UI */
.assistant-message { display: block; }
.assistant-thoughts { margin-bottom: 8px; }