From d28d88d1f22adecc6f2d1a927fe6e387e75940cd Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 25 Aug 2025 21:13:09 +0200 Subject: [PATCH] feat(ui,markdown): add collapsible 'Thoughts' for , sanitize titles, improve focus UX; refactor table/blockquote render & styles Backend: HTML-unescape and strip / 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 removal to handle streaming; drop table wrapper div (emit ); 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. --- backend/main.py | 18 ++++- electron/main.cjs | 4 + electron/preload.cjs | 3 +- src/App.jsx | 180 ++++++++++++++++++++++++++++++++----------- src/colorSchemes.js | 2 +- src/markdown.js | 110 ++++++++++++++------------ src/styles.css | 98 +++++++++++++++++++++++ 7 files changed, 319 insertions(+), 96 deletions(-) diff --git a/backend/main.py b/backend/main.py index 51e3eb6..22bb0fc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 blocks from the unescaped title + # Use re.IGNORECASE to handle potential variations in casing (e.g., ) + cleaned_title = re.sub(r'.*?', '', 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)): diff --git a/electron/main.cjs b/electron/main.cjs index 3d8367f..cdc8cf9 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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' }) diff --git a/electron/preload.cjs b/electron/preload.cjs index 895b088..0eb27d4 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -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) }) diff --git a/src/App.jsx b/src/App.jsx index 7f5251d..67845e4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 or block (first occurrence) and return { think, answer } +function splitThinkBlocks(text) { + if (!text) return { think: null, answer: '' }; + + const openTagRe = //i; + const closeTagRe = /<\/think(?:ing)?>/i; + + const openMatch = text.match(openTagRe); + + if (!openMatch) { + // No opening 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 ( +
+ {showThinkButton && ( +
+ + {open && ( +
+ )} +
+ )} +
+
+ ); +} 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(/[\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 (
@@ -799,10 +889,13 @@ export default function App() { Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}
-
+
{messages.map((m, i) => (
-
+ {m.role === 'assistant' + ? + :
+ }
))}
@@ -822,6 +915,7 @@ export default function App() {
setInput(e.target.value)} diff --git a/src/colorSchemes.js b/src/colorSchemes.js index 3e0dc2c..7f0e217 100644 --- a/src/colorSchemes.js +++ b/src/colorSchemes.js @@ -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', diff --git a/src/markdown.js b/src/markdown.js index a5f50cb..1c3c4bf 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -1,9 +1,9 @@ export function markdownToHTML(text) { // 0) Remove .../... blocks - text = text.replace( - /(^|\n)\s*[\s\S]*?<\/think(?:ing)?>\s*(\n\s*\n)?/gi, - (_, lead) => (lead ? '\n' : '') - ); + // This regex will match an an opening or tag, + // followed by any characters (non-greedy), until either a closing + // or tag is found, OR the end of the string ($). + text = text.replace(/[\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}
${lines}
`; + } + ); + // 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 => `
  • ${item}
  • `) .join(''); return `${lead}
      ${items}
    `; @@ -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)=>`${h}`).join(''); - const rows = bodyLines.map(line => { - const cells = split(line); - const tds = cells.map((c,i)=>`${c}`).join(''); - return `
    ${tds}`; - }).join(''); - - // Wrapper: rounded outer border + top/bottom spacing; inner cells keep their own borders. - const wrapperOpen = `
    `; - const table = `
    ${ths}${rows}
    `; - - 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)=>`${h}`).join(''); + const rows = bodyLines.map(line => { + const cells = split(line); + const tds = cells.map((c,i)=>`${c}`).join(''); + return `${tds}`; + }).join(''); + + const table = `${ths}${rows}
    `; + + return table + (hadTrailingNewline ? '\n' : ''); +}); + // 4.75) Horizontal rules escaped = escaped.replace(/^---\s*$/gm, "
    "); @@ -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, ">"); - const head = `
    ${title}
    `; + const head = `
    ${title}
    `; const body = `
    ${escapedCode}
    `; - return `
    ${head}${body}
    `; + return `
    ${head}${body}
    `; }); // 7) Convert line-breaks to
    @@ -126,12 +134,16 @@ export function markdownToHTML(text) { .replace(/
    \s*(
    ]*>)/g, "$1") .replace(/(<\/div>)\s*
    /g, "$1") .replace(/
    \s*(]*>)/g, "$1") - .replace(/(<\/table>)\s*
    /g, "$1"); + .replace(/(<\/table>)\s*
    /g, "$1") + .replace(/
    \s*(
    )/g, "$1") // New: Cleanup
    before blockquote + .replace(/(<\/blockquote>)\s*
    /g, "$1"); // New: Cleanup
    after blockquote - // 9) Trim spaces/tabs and remove empty newline(s) immediately after
    + // 9) Trim spaces/tabs and remove empty newline(s) immediately after
    , blockquote, and ul html = html .replace(/(
    )[ \t]+/g, "$1") // remove spaces/tabs - .replace(/(
    )(?:[ \t]*
    )+/g, "$1"); // remove one or more blank lines (now
    ) after
    + .replace(/(
    )(?:[ \t]*
    )+/g, "$1") // remove one or more blank lines (now
    ) after
    + .replace(/(<\/blockquote>)(?:[ \t]*
    )+/g, "$1") // New: Remove empty lines after blockquote + .replace(/(<\/ul>)(?:[ \t]*
    )+/g, "$1"); // New: Remove empty lines after ul return html; -} \ No newline at end of file +} diff --git a/src/styles.css b/src/styles.css index 5808d48..2820140 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 */ +}