diff --git a/backend/src/spicebook/routers/chat.py b/backend/src/spicebook/routers/chat.py index e43e8d7..2f5e4e0 100644 --- a/backend/src/spicebook/routers/chat.py +++ b/backend/src/spicebook/routers/chat.py @@ -23,12 +23,12 @@ def _sse_event(event: str, data: dict | list) -> str: return f"event: {event}\ndata: {payload}\n\n" -def _build_notebook_context(req: ChatStreamRequest) -> str: +async def _build_notebook_context(req: ChatStreamRequest) -> str: """Extract SPICE cells and markdown notes from the notebook for LLM context.""" if not req.notebook or not req.notebook.notebook_id: return "" - nb = load_notebook(settings.notebook_dir, req.notebook.notebook_id) + nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id) if nb is None: return "" @@ -64,7 +64,7 @@ async def chat_stream(req: ChatStreamRequest): async def generate(): # Build context from the notebook if available - context = _build_notebook_context(req) + context = await _build_notebook_context(req) question = req.question if req.notebook and req.notebook.title: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ae64bd..acfbb61 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,7 +28,10 @@ "astro-seo-meta": "^5.2.0", "clsx": "^2.1.0", "dompurify": "^3.3.1", + "katex": "^0.16.33", "lucide-react": "^0.468.0", + "marked": "^17.0.3", + "marked-katex-extension": "^5.1.7", "react": "^19.0.0", "react-dom": "^19.0.0", "satori": "^0.19.2", @@ -6123,6 +6126,31 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.16.33", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", + "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6479,6 +6507,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-katex-extension": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.7.tgz", + "integrity": "sha512-CVFzrqwpXGVaHByqcVvO/JfzW/OMWrAF3pEfNYNIruzBzM64moANSHapCg1qbzEN+NGf5unHwkMfwJIXHzyDAw==", + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16 <0.17", + "marked": ">=4 <18" + } + }, "node_modules/mdast-util-definitions": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e5b56a9..958ca83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,10 @@ "astro-seo-meta": "^5.2.0", "clsx": "^2.1.0", "dompurify": "^3.3.1", + "katex": "^0.16.33", "lucide-react": "^0.468.0", + "marked": "^17.0.3", + "marked-katex-extension": "^5.1.7", "react": "^19.0.0", "react-dom": "^19.0.0", "satori": "^0.19.2", diff --git a/frontend/src/components/chat/ChatWidget.tsx b/frontend/src/components/chat/ChatWidget.tsx index f781069..53fbd9f 100644 --- a/frontend/src/components/chat/ChatWidget.tsx +++ b/frontend/src/components/chat/ChatWidget.tsx @@ -12,33 +12,63 @@ import { useChatStore } from '../../lib/chat-store'; import type { ChatMessage } from '../../lib/chat-store'; import { streamChat } from '../../lib/chat-api'; import { useNotebookStore } from '../../lib/notebook-store'; +import { marked } from 'marked'; +import markedKatex from 'marked-katex-extension'; +import DOMPurify from 'dompurify'; +import 'katex/dist/katex.min.css'; import '../../styles/chat-widget.css'; -// ── Lightweight markdown ──────────────────────────────── +// ── Markdown rendering (marked + KaTeX + DOMPurify) ────── -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); +marked.setOptions({ + breaks: true, + gfm: true, +}); + +marked.use(markedKatex({ + throwOnError: false, + nonStandard: true, +})); + +// KaTeX generates SVG + spans with specific classes/attributes. +// DOMPurify must allow these through. +const KATEX_TAGS = [ + 'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', + 'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot', + 'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose', + 'annotation', 'annotation-xml', +]; + +const KATEX_ATTRS = [ + 'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel', + 'fence', 'stretchy', 'symmetric', 'lspace', 'rspace', + 'linethickness', 'columnalign', 'rowalign', 'columnspacing', + 'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing', + 'width', 'height', 'voffset', 'accent', 'accentunder', + 'notation', 'minsize', 'maxsize', 'movablelimits', + 'aria-hidden', 'focusable', 'role', 'tabindex', + 'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke', + 'stroke-width', 'stroke-linecap', 'stroke-linejoin', + 'transform', 'clip-path', +]; + +// LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext") +// but marked-katex-extension requires $$ on its own line for display mode. +// This normalizer ensures $$ delimiters get their own lines. +function normalizeDisplayMath(text: string): string { + return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => { + const trimmed = inner.trim(); + return `\n$$\n${trimmed}\n$$\n`; + }); } function renderMarkdown(text: string): string { - let html = escapeHtml(text); - // Code blocks (``` ... ```) - html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => { - return `
${code.trim()}`;
+ const normalized = normalizeDisplayMath(text);
+ const raw = marked.parse(normalized, { async: false }) as string;
+ return DOMPurify.sanitize(raw, {
+ ADD_TAGS: KATEX_TAGS,
+ ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'],
});
- html = html.replace(/\*\*(.+?)\*\*/g, '$1');
- html = html.replace(/(?$1');
- html = html.replace(/`([^`]+?)`/g, '$1');
- html = html.replace(
- /\[([^\]]+?)\]\((https?:\/\/[^)]+?)\)/g,
- '$1',
- );
- html = html.replace(/\n/g, '