feat: Add streaming chat + scroll persistence; improve markdown & links
Backend
- /chat: support streaming via StreamingResponse; save full reply after stream ends. Non-stream path unchanged.
- ChatRequest: add stream flag (default false).
- GenerateTitleRequest: add model and use it instead of hardcoded llama3.
- ollama_client.chat_stream(): new async generator parsing Ollama streaming JSON (both formats).
- Remove response_model from /chat to allow streaming; non-stream still returns { reply }.
Electron
- Open external links in system browser (setWindowOpenHandler, shell.openExternal).
- New IPC: update-settings, open-external-link.
- Set minimum window size; preload exposes updateSettings and openExternalLink.
Frontend (React)
- Streaming UI with live chunking; sticky-bottom only when user at bottom.
- Per-session scroll persistence and robust restore.
- New message tip to jump to latest reply when scrolled up.
- Disable Send while sending; spinner.
- General Settings: stream output toggle; propagate model/stream changes.
- Apply color scheme at boot; extract colorSchemes helper.
- Sidebar UX tweaks and unread badges.
Markdown/rendering
- Code blocks: language title bar and wrapper.
- Tables: GitHub-style parsing, per-cell borders, rounded wrapper, spacing, alignment.
- Headings: remove blank line after h1-h4.
- <hr>: handle after tables; strip following whitespace.
- Links: target=_blank with icon and URL tooltip.
Styles
- Add styles for code/table wrappers, new-message tip, toggle, spinner; hover/active vars; narrower sidebar.
API notes / breaking changes
- /chat accepts stream=true and returns text/plain streamed chunks.
- generate-title now requires a model.
- Non-stream /chat response shape unchanged.
This commit is contained in:
@@ -11,8 +11,8 @@ export function markdownToHTML(text) {
|
||||
// 2) Extract code blocks and replace with placeholders
|
||||
const codeblocks = [];
|
||||
const placeholder = idx => `@@CODEBLOCK${idx}@@`;
|
||||
tmp = tmp.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
codeblocks.push({ lang, code });
|
||||
tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
codeblocks.push({ lang: (lang || '').trim(), code });
|
||||
return placeholder(codeblocks.length - 1);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,12 @@ export function markdownToHTML(text) {
|
||||
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
||||
.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
||||
|
||||
// 4.2) Remove blank empty lines immediately after headings
|
||||
escaped = escaped.replace(
|
||||
/(<h[1-4]>.*?<\/h[1-4]>)[ \t]*\n(?:[ \t]*\n)+/g,
|
||||
"$1\n"
|
||||
);
|
||||
|
||||
// 4.5) Unordered lists
|
||||
escaped = escaped.replace(
|
||||
/(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g,
|
||||
@@ -42,26 +48,90 @@ 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;
|
||||
|
||||
escaped = escaped.replace(mdTableBlockRe, (block) => {
|
||||
// Preserve trailing newline so '^---$' can match next line
|
||||
const hadTrailingNewline = /\n$/.test(block);
|
||||
const lines = block.replace(/\n$/, '').split('\n');
|
||||
|
||||
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;
|
||||
|
||||
// Alignment per column (default left). Vertical-align top by default.
|
||||
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;border:1px solid #e5e7eb;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('');
|
||||
|
||||
// Wrapper: rounded outer border + top/bottom spacing; inner cells keep their own borders.
|
||||
const wrapperOpen = `<div class="md-table" style="margin:1rem 0;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden">`;
|
||||
const table = `<table class="nice" style="border-collapse:separate;border-spacing:0;width:100%"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
|
||||
return wrapperOpen + table + (hadTrailingNewline ? '\n' : '');
|
||||
});
|
||||
|
||||
// 4.75) Horizontal rules
|
||||
escaped = escaped.replace(/^---\s*$/gm, "<hr>");
|
||||
|
||||
// 5) Bold, italic, inline code
|
||||
let html = escaped
|
||||
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
||||
.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<i>$1</i>")
|
||||
.replace(/`(.+?)`/g, "<code>$1</code>");
|
||||
|
||||
// 6) Restore code blocks
|
||||
// 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, ">");
|
||||
return `<pre><code class="language-${lang}">${escapedCode}</code></pre>`;
|
||||
const head = `<div class="md-code-head" style="background:#f9fafb;border-bottom:1px solid #e5e7eb;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 #e5e7eb;border-radius:12px;overflow:hidden">${head}${body}</div>`;
|
||||
});
|
||||
|
||||
// 7) Convert line-breaks to <br>
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
|
||||
// 8) Cleanup stray <br> immediately before/after lists
|
||||
// 8) Cleanup stray <br> around lists/tables/wrappers
|
||||
html = html
|
||||
.replace(/<br>\s*(<ul>)/g, "$1")
|
||||
.replace(/(<\/ul>)\s*<br>/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");
|
||||
|
||||
// 9) Trim spaces/tabs and remove empty newline(s) immediately after <hr>
|
||||
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>
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user