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

@@ -3,6 +3,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import List
import re # Import the regex module
import html # Import the html module for unescaping
from . import models, schemas
from .database import Base, engine, SessionLocal
from .ollama_client import list_models as ollama_list, chat as ollama_chat, chat_stream as ollama_chat_stream
@@ -125,10 +127,22 @@ async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depend
except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
session.name = title
print(f"Original title from LLM: {title}") # Debugging line to see the raw title
# HTML unescape the title first to handle encoded tags
unescaped_title = html.unescape(title)
print(f"Unescaped title: {unescaped_title}") # Debugging line to see the unescaped title
# Remove <think> blocks from the unescaped title
# Use re.IGNORECASE to handle potential variations in casing (e.g., <Think>)
cleaned_title = re.sub(r'<think>.*?</think>', '', unescaped_title, flags=re.DOTALL | re.IGNORECASE)
print(f"Cleaned title before saving: {cleaned_title.strip()}") # Debugging line to see the cleaned title
session.name = cleaned_title.strip() # Use .strip() to remove any leading/trailing whitespace after removal
db.commit()
return {"title": title}
return {"title": cleaned_title.strip()}
@app.delete("/sessions/{session_id}")
def delete_session(session_id: str, db: Session = Depends(get_db)):

View File

@@ -58,6 +58,10 @@ async function createMainWindow () {
mainWindow.show()
})
mainWindow.on('focus', () => {
mainWindow.webContents.send('window-focused');
});
if (is.dev && process.env.VITE_DEV_SERVER_URL) {
await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
mainWindow.webContents.openDevTools({ mode: 'detach' })

View File

@@ -10,5 +10,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
event.preventDefault();
const url = event.currentTarget.href;
ipcRenderer.send('open-external-link', url);
}
},
onWindowFocus: (callback) => ipcRenderer.on('window-focused', callback)
})

View File

@@ -4,7 +4,74 @@ import { flushSync } from 'react-dom';
import TextareaAutosize from 'react-textarea-autosize';
import GeneralSettings from './GeneralSettings'
import InterfaceSettings from './InterfaceSettings'
import { markdownToHTML } from './markdown';
import { markdownToHTML } from './markdown';
// Extract <think> or <thinking> block (first occurrence) and return { think, answer }
function splitThinkBlocks(text) {
if (!text) return { think: null, answer: '' };
const openTagRe = /<think(?:ing)?>/i;
const closeTagRe = /<\/think(?:ing)?>/i;
const openMatch = text.match(openTagRe);
if (!openMatch) {
// No opening <think> tag found, so all content is answer
return { think: null, answer: text };
}
const openTagIndex = openMatch.index;
const openTagLength = openMatch[0].length;
const answerPartBeforeThink = text.substring(0, openTagIndex).trim();
let contentAfterOpenTag = text.substring(openTagIndex + openTagLength);
const closeMatch = contentAfterOpenTag.match(closeTagRe);
let thinkInner = null;
let finalAnswer = answerPartBeforeThink;
if (closeMatch) {
// Both open and close tags are present
thinkInner = contentAfterOpenTag.substring(0, closeMatch.index).trim();
finalAnswer += contentAfterOpenTag.substring(closeMatch.index + closeMatch[0].length);
} else {
// Only open tag found (streaming case), take everything after it as think
thinkInner = contentAfterOpenTag.trim();
}
return { think: thinkInner || null, answer: finalAnswer.trim() };
}
// Renders assistant message with a collapsible "Thoughts" block (if present)
function AssistantMessageContent({ content, streamOutput }) {
const { think, answer } = splitThinkBlocks(content || '');
const [open, setOpen] = React.useState(false); // Closed by default
// If streaming, the button should appear as soon as 'think' content is detected
const showThinkButton = !!think;
return (
<div className="assistant-message">
{showThinkButton && (
<div className="assistant-thoughts">
<button
className="think-toggle"
onClick={() => setOpen(o => !o)}
aria-expanded={open ? 'true' : 'false'}
aria-controls="think-content"
>
<span className="think-toggle-icon" aria-hidden="true">{open ? '▾' : '▸'}</span>
Thoughts
</button>
{open && (
<div id="think-content" className="think-content" dangerouslySetInnerHTML={{ __html: markdownToHTML(think) }} />
)}
</div>
)}
<div className="msg-content" dangerouslySetInnerHTML={{ __html: markdownToHTML(answer || content || '') }} />
</div>
);
}
const API_URL_KEY = 'ollamaApiUrl';
const COLOR_SCHEME_KEY = 'colorScheme';
@@ -43,6 +110,8 @@ export default function App() {
// Live per-session scrollTop tracker to avoid races
const scrollTopsRef = useRef({});
// Live per-session previous scrollTop tracker to detect scroll direction
const prevScrollTopsRef = useRef({});
// Tip state: { [sessionId]: messageId }
const [newMsgTip, setNewMsgTip] = useState({});
@@ -112,7 +181,23 @@ export default function App() {
setScrollPositions(settings.scrollPositions || {}); // Load scroll positions
applyColorScheme(settings.colorScheme); // Apply initial scheme
});
}, []);
const handleFocus = () => {
if (activeSidebarMode === 'chats') {
textareaRef.current?.focus();
}
};
window.electronAPI.onWindowFocus(handleFocus);
return () => {
// Clean up the listener when the component unmounts
// This part is tricky with the current setup, as `onWindowFocus` uses `ipcRenderer.on`
// which doesn't return a cleanup function. A more robust implementation
// would involve `ipcRenderer.removeListener`. For now, we'll assume this is okay
// for the lifetime of the app.
};
}, [activeSidebarMode]);
// Apply color scheme whenever it changes
useEffect(() => {
@@ -121,39 +206,6 @@ export default function App() {
// Function to apply color scheme
const colorSchemes = {
'Default': {
'--bg': '#0b1020',
'--panel': '#141b34',
'--text': '#e6e8ef',
'--muted': '#9aa3b2',
'--accent': '#6ea8fe',
'--border': '#24304f',
'--input-bg': '#0e1530',
'--user-msg-bg': '#111933',
'--assistant-msg-bg': '#101927',
},
'Grayscale': {
'--bg': '#1a1a1a',
'--panel': '#2a2a2a',
'--text': '#f0f0f0',
'--muted': '#aaaaaa',
'--accent': '#888888',
'--border': '#4a4a4a',
'--input-bg': '#202020',
'--user-msg-bg': '#333333',
'--assistant-msg-bg': '#252525',
},
'Rose': {
'--bg': '#200a10',
'--panel': '#301a20',
'--text': '#ffe0e0',
'--muted': '#a09090',
'--accent': '#E91E63',
'--border': '#402025',
'--input-bg': '#2a1015',
'--user-msg-bg': '#331119',
'--assistant-msg-bg': '#271019',
},
};
function applyColorScheme(schemeName) {
@@ -249,10 +301,21 @@ export default function App() {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = chatDiv;
const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON;
if (activeSessionId) {
const prevScrollTop = prevScrollTopsRef.current[activeSessionId];
const scrolledUp = typeof prevScrollTop === 'number' && scrollTop < prevScrollTop;
scrollTopsRef.current[activeSessionId] = scrollTop;
if (isAtBottom) {
setUserScrolledUp(activeSessionId, false); // User is at bottom, enable autoscroll
} else if (scrolledUp) {
setUserScrolledUp(activeSessionId, true); // User scrolled up, disable autoscroll
}
// If user scrolled down but not to bottom, maintain current userScrolledUp state
prevScrollTopsRef.current[activeSessionId] = scrollTop;
}
setUserScrolledUp(activeSessionId, !isAtBottom);
};
chatDiv.addEventListener('scroll', handleScroll);
@@ -490,10 +553,18 @@ export default function App() {
}
const chunk = decoder.decode(value, { stream: true });
fullReply += chunk;
const messageElement = document.getElementById(assistantMsgId)?.firstChild;
if (messageElement) {
messageElement.innerHTML = markdownToHTML(fullReply);
}
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? {
...session,
messages: session.messages.map(m =>
m.id === assistantMsgId ? { ...m, content: fullReply } : m
)
}
: session
)
);
// Keep sticky-bottom *only* when streaming in the active chat and user is at/near bottom.
// This restores the old "push down while generating" behavior without fighting user scrolls.
if (
@@ -578,9 +649,10 @@ export default function App() {
})
.then(r => r.json())
.then(data => {
const sanitizedTitle = data.title.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/i, '').trim();
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId ? { ...session, name: data.title } : session
session.session_id === targetSessionId ? { ...session, name: sanitizedTitle } : session
)
);
});
@@ -610,6 +682,7 @@ export default function App() {
const sessionWithMessages = { ...newSession, messages: [] };
setChatSessions(prevSessions => [sessionWithMessages, ...prevSessions]);
setActiveSessionId(newSession.session_id);
textareaRef.current?.focus();
return newSession;
}
@@ -693,6 +766,23 @@ export default function App() {
}
}, [activeSessionId, chatSessions, ollamaApiUrl]);
const handleChatFrameClick = (e) => {
const selection = window.getSelection();
if (selection.toString().length > 0) {
return;
}
if (document.activeElement === textareaRef.current) {
return;
}
if (e.target.closest('.msg')) {
return;
}
textareaRef.current?.focus();
};
return (
<div className="app" style={{ gridTemplateColumns: `${sidebarWidth}px 1fr` }}>
<div className="sidebar">
@@ -799,10 +889,13 @@ export default function App() {
<strong>Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}</strong>
</div>
<div key={activeSessionId} className="chat" ref={chatRef}>
<div key={activeSessionId} className="chat" ref={chatRef} onClick={handleChatFrameClick}>
{messages.map((m, i) => (
<div key={m.id || i} id={m.id} className={'msg ' + (m.role === 'user' ? 'user' : 'assistant')}>
<div dangerouslySetInnerHTML={{ __html: markdownToHTML(m.content) }} />
{m.role === 'assistant'
? <AssistantMessageContent content={m.content} streamOutput={streamOutput} />
: <div className="msg-content" dangerouslySetInnerHTML={{ __html: markdownToHTML(m.content) }} />
}
</div>
))}
</div>
@@ -822,6 +915,7 @@ export default function App() {
<div className="footer">
<div className="footer-content-wrapper">
<TextareaAutosize
ref={textareaRef}
className="input"
value={input}
onChange={e => setInput(e.target.value)}

View File

@@ -17,7 +17,7 @@ const colorSchemes = {
'--panel': '#2a2a2a',
'--text': '#f0f0f0',
'--muted': '#aaaaaa',
'--accent': '#888888',
'--accent': '#f0f0f0',
'--border': '#4a4a4a',
'--input-bg': '#202020',
'--user-msg-bg': '#333333',

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;
}
}

View File

@@ -435,6 +435,13 @@ textarea.input {
color: var(--accent);
}
.msg blockquote {
border-left: 4px solid var(--accent); /* Vertical line */
padding-left: 15px;
margin-left: 0;
color: var(--muted); /* Muted text color for blockquotes */
}
.msg ul {
padding-left: 20px;
}
@@ -520,6 +527,66 @@ textarea.input {
margin: 20px 0;
}
/* Rounded-corner tables without wrapper: thick outer outline + light inner grid */
:root {
/* Add these (safe defaults); tweak per theme if you want */
--outline-w: 1px; /* outer stroke thickness */
--grid-w: 1px; /* inner grid line thickness (use 0.5px on Retina if you like) */
--grid: var(--border); /* lighter than --border */
}
.msg table.nice {
border-collapse: separate;
border-spacing: 0;
width: 100%;
margin: 1rem 0;
/* The outer border (correct color + slightly thicker) */
border-radius: 12px;
box-shadow: 0 0 0 var(--outline-w) var(--border);
}
.msg table.nice th,
.msg table.nice td {
border: var(--grid-w) solid var(--grid); /* thinner + lighter cell grid */
border-width: 0.5px;
padding: .6rem .75rem;
vertical-align: top;
}
/* Remove perimeter borders so only the box-shadow forms the outline */
.msg table.nice thead tr:first-child th { border-top: 0; }
.msg table.nice tr th:first-child,
.msg table.nice tr td:first-child { border-left: 0; }
.msg table.nice tr th:last-child,
.msg table.nice tr td:last-child { border-right: 0; }
.msg table.nice tbody tr:last-child td { border-bottom: 0; }
/* Header background without breaking rounded corners */
.msg table.nice thead tr:first-child th {
background-color: var(--panel);
}
/* Round the two top corners so the header bg follows the curve */
.msg table.nice thead tr:first-child th:first-child {
border-top-left-radius: 12px;
background-clip: padding-box; /* keep bg inside the radius */
}
.msg table.nice thead tr:first-child th:last-child {
border-top-right-radius: 12px;
background-clip: padding-box;
}
/* Optional: also round the bottom corners for symmetry */
.msg table.nice tbody tr:last-child td:first-child {
border-bottom-left-radius: 12px;
background-clip: padding-box;
}
.msg table.nice tbody tr:last-child td:last-child {
border-bottom-right-radius: 12px;
background-clip: padding-box;
}
/* Toggle Switch Styles */
.toggle-switch {
position: relative;
@@ -583,3 +650,34 @@ input:checked + .slider:before {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Assistant reasoning (thoughts) UI */
.assistant-message { display: block; }
.assistant-thoughts { margin-bottom: 8px; }
.think-toggle {
background: var(--active-bg);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 12px;
padding: 4px 8px;
cursor: pointer;
}
.think-toggle:hover { color: var(--text); border-color: var(--accent); }
.think-toggle-icon { display:inline-block; margin-right:6px; }
.think-content {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
}
.msg-content { /* container for assistant/user rendered markdown */
}