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 => {