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:
@@ -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)):
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
180
src/App.jsx
180
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 <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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
110
src/markdown.js
110
src/markdown.js
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user