feat(ui,markdown): add collapsible 'Thoughts' for <think>, sanitize titles, improve focus UX; refactor table/blockquote render & styles

Backend: HTML-unescape and strip <think>/<thinking> from generated titles; trim and return cleaned value; add debug logs.

Electron: send 'window-focused' from main; expose onWindowFocus in preload.

Frontend: stream-safe AssistantMessageContent with collapsible Thoughts; switch streaming render to React state updates; autofocus textarea on window focus/new chat and when clicking empty chat area; sanitize session title client-side.

Markdown: support blockquotes; allow '-' or '*' bullets; simplify <think> removal to handle streaming; drop table wrapper div (emit <table class='nice'>); theme-aware code block headers/borders.

CSS: rounded 'nice' tables with light inner grid; blockquote styling; Thoughts toggle/panel styles.

Color: brighten Grayscale --accent.

Follow-ups: add IPC listener cleanup; ensure single source of truth for colorSchemes.
This commit is contained in:
2025-08-25 21:13:09 +02:00
parent 41c69abe28
commit d28d88d1f2
7 changed files with 319 additions and 96 deletions

View File

@@ -1,9 +1,9 @@
export function markdownToHTML(text) {
// 0) Remove <think>...</think>/<thinking>...</thinking> blocks
text = text.replace(
/(^|\n)\s*<think>[\s\S]*?<\/think(?:ing)?>\s*(\n\s*\n)?/gi,
(_, lead) => (lead ? '\n' : '')
);
// This regex will match an an opening <think> or <thinking> tag,
// followed by any characters (non-greedy), until either a closing
// </think> or </thinking> tag is found, OR the end of the string ($).
text = text.replace(/<think(?:ing)?>[\s\S]*?(?:<\/think(?:ing)?>|$)/gi, '');
// 1) Normalize line endings
let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -35,13 +35,25 @@ export function markdownToHTML(text) {
"$1\n"
);
// 4.3) Blockquotes
escaped = escaped.replace(
/(^|\n)([ \t]*> .+(?:\n[ \t]*> .+)*)/g,
(_, lead, blockquoteBlock) => {
const lines = blockquoteBlock
.split(/\n/)
.map(line => line.replace(/^[ \t]*>\s*/, '').trim())
.join('\n');
return `${lead}<blockquote>${lines}</blockquote>`;
}
);
// 4.5) Unordered lists
escaped = escaped.replace(
/(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g,
/(^|\n)([ \t]*[-*] .+(?:\n[ \t]*[-*] .+)*)/g,
(_, lead, listBlock) => {
const items = listBlock
.split(/\n/)
.map(line => line.replace(/^[ \t]*\*\s+/, '').trim())
.map(line => line.replace(/^[ \t]*[-*]\s+/, '').trim())
.map(item => `<li>${item}</li>`)
.join('');
return `${lead}<ul>${items}</ul>`;
@@ -49,49 +61,45 @@ 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) => {
// Preserve trailing newline so '^---$' can match next line
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;
// 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' : '');
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' : '');
});
// 4.75) Horizontal rules
escaped = escaped.replace(/^---\s*$/gm, "<hr>");
@@ -109,9 +117,9 @@ export function markdownToHTML(text) {
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:#f9fafb;border-bottom:1px solid #e5e7eb;padding:.6rem .75rem;font-weight:600">${title}</div>`;
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 #e5e7eb;border-radius:12px;overflow:hidden">${head}${body}</div>`;
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>
@@ -126,12 +134,16 @@ export function markdownToHTML(text) {
.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(/(<\/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
// 9) Trim spaces/tabs and remove empty newline(s) immediately after <hr>
// 9) Trim spaces/tabs and remove empty newline(s) immediately after <hr>, blockquote, and 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(/(<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
return html;
}
}