diff --git a/CLAUDE.md b/CLAUDE.md index 1317f5d..8779567 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte ### Markdown レンダリング アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。 +数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。 ### モデル切替 diff --git a/docs/architecture.md b/docs/architecture.md index 4407178..e50c228 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -201,6 +201,13 @@ frontend/src/ - アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ - ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画 +- 数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(`mathExtension` / `mathSanitizeConfig`) + - ブロック: 行頭の `$$ ... $$` と `\[ ... \]`。インライン: `$ ... $`、`\( ... \)`、行中の `$$ ... $$` + - `$ ... $` は誤爆防止のため「開き `$` の直後が空白でない」「中身に改行を含まない」「閉じ `$` の直後が数字でない」を満たすものだけを数式にする(`$100 から $200` のような金額表記は素通しする)。リテラルのドル記号は `\$` でエスケープできる + - インラインコード・コードフェンス内の `$` は marked のトークナイザ順により数式にならない + - 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(`throwOnError: false`) + - KaTeX の MathML 出力に含まれる `semantics` / `annotation` は DOMPurify の既定では許可されないため、`mathSanitizeConfig` で明示的に許可している + - ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される ### Vite プロキシ設定 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8cbb31..4cc422d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "dompurify": "^3.1.0", + "katex": "^0.16.47", "marked": "^12.0.0", "pinia": "^2.1.7", "vue": "^3.4.0", @@ -1117,6 +1118,15 @@ "balanced-match": "^1.0.0" } }, + "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/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1242,6 +1252,22 @@ "he": "bin/he" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6649af2..a3e7009 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "dompurify": "^3.1.0", + "katex": "^0.16.47", "marked": "^12.0.0", "pinia": "^2.1.7", "vue": "^3.4.0", diff --git a/frontend/src/components/MarkdownContent.vue b/frontend/src/components/MarkdownContent.vue index 965b1ab..c88e44f 100644 --- a/frontend/src/components/MarkdownContent.vue +++ b/frontend/src/components/MarkdownContent.vue @@ -2,6 +2,8 @@ import { computed } from 'vue' import { marked } from 'marked' import DOMPurify from 'dompurify' +import 'katex/dist/katex.min.css' +import { mathExtension, mathSanitizeConfig } from '../utils/math' const props = defineProps<{ content: string @@ -9,6 +11,7 @@ const props = defineProps<{ }>() marked.setOptions({ breaks: true, gfm: true }) +marked.use(mathExtension) function closeOpenFences(src: string): string { const fenceCount = (src.match(/^```/gm) ?? []).length @@ -18,7 +21,7 @@ function closeOpenFences(src: string): string { const html = computed(() => { const raw = props.pending ? closeOpenFences(props.content) : props.content - return DOMPurify.sanitize(marked.parse(raw) as string) + return DOMPurify.sanitize(marked.parse(raw) as string, mathSanitizeConfig) }) @@ -104,6 +107,15 @@ const html = computed(() => { /* 水平線 */ .md :deep(hr) { border-color: #333; margin: 10px 0; } +/* 数式 (KaTeX) */ +.md :deep(.katex) { font-size: 1.05em; } +.md :deep(.katex-display) { + margin: 10px 0; + padding: 2px 0; + overflow-x: auto; + overflow-y: hidden; +} + /* リンク */ .md :deep(a) { color: #60a5fa; text-decoration: none; } .md :deep(a:hover) { text-decoration: underline; } diff --git a/frontend/src/utils/math.ts b/frontend/src/utils/math.ts new file mode 100644 index 0000000..0865a69 --- /dev/null +++ b/frontend/src/utils/math.ts @@ -0,0 +1,103 @@ +import katex from 'katex' +import type { MarkedExtension, TokenizerAndRendererExtension, Tokens } from 'marked' + +/** + * marked 拡張として数式 (KaTeX) を描画する。 + * + * 対応する区切り記号: + * - ブロック: `$$ ... $$` / `\[ ... \]`(単独行) + * - インライン: `$ ... $` / `\( ... \)` / 行中の `$$ ... $$` + * + * `$` はドル記号としても書かれるため、インラインの `$ ... $` だけは + * 誤爆を避けるための条件を付けている(下記 INLINE_DOLLAR 参照)。 + * 明示的にドル記号を書きたい場合は Markdown のエスケープ `\$` が使える。 + */ + +// ブロック: 行頭(インデント 3 まで)から始まる $$ ... $$ / \[ ... \] +const BLOCK_DOLLAR = /^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n+|$)/ +const BLOCK_BRACKET = /^ {0,3}\\\[([\s\S]+?)\\\][ \t]*(?:\n+|$)/ + +// インライン: \( ... \) と、段落の途中に置かれた $$ ... $$ +const INLINE_PAREN = /^\\\(((?:\\[^]|[^\\])+?)\\\)/ +const INLINE_DOUBLE = /^\$\$((?:\\[^]|[^\\])+?)\$\$/ +// インライン: $ ... $ +// - 開き `$` の直後に空白・`$` が来るものは対象外 +// - 中身に改行と裸の `$` を含まない +// - 閉じ `$` の直後が数字のものは対象外("$100から$200" のような金額表記の除外) +// - 中身が空白で終わるものは対象外(tokenizer 側で判定) +const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\\$\n])+?)\$(?!\d)/ + +function escapeHtml(src: string): string { + return src + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function renderMath(tex: string, displayMode: boolean, raw: string): string { + try { + return katex.renderToString(tex.trim(), { + displayMode, + throwOnError: false, // 構文エラーは例外にせず赤字で表示する + errorColor: '#f87171', // ダーク背景で読める色にする + strict: false, // 日本語混じりなどの警告を出さない + output: 'htmlAndMathml', + }) + } catch { + // KaTeX が想定外の失敗をした場合は元の文字列をそのまま見せる + return escapeHtml(raw) + } +} + +const blockMath: TokenizerAndRendererExtension = { + name: 'blockMath', + level: 'block', + start(src: string) { + return src.match(/(?:^|\n) {0,3}(?:\$\$|\\\[)/)?.index + }, + tokenizer(src: string) { + const match = BLOCK_DOLLAR.exec(src) ?? BLOCK_BRACKET.exec(src) + if (!match) return undefined + return { type: 'blockMath', raw: match[0], text: match[1] } + }, + renderer(token: Tokens.Generic) { + return renderMath(token.text as string, true, token.raw as string) + }, +} + +const inlineMath: TokenizerAndRendererExtension = { + name: 'inlineMath', + level: 'inline', + start(src: string) { + // エスケープされた \$ は数式の開始とみなさない + const match = /(^|[^\\])(\$|\\\()/.exec(src) + return match ? match.index + match[1].length : undefined + }, + tokenizer(src: string) { + const paren = INLINE_PAREN.exec(src) + if (paren) return { type: 'inlineMath', raw: paren[0], text: paren[1], display: false } + + const double = INLINE_DOUBLE.exec(src) + if (double) return { type: 'inlineMath', raw: double[0], text: double[1], display: true } + + const single = INLINE_DOLLAR.exec(src) + if (single && !/[\s\\]$/.test(single[1])) { + return { type: 'inlineMath', raw: single[0], text: single[1], display: false } + } + return undefined + }, + renderer(token: Tokens.Generic) { + return renderMath(token.text as string, token.display === true, token.raw as string) + }, +} + +export const mathExtension: MarkedExtension = { + extensions: [blockMath, inlineMath], +} + +/** KaTeX が出力する MathML 用に DOMPurify へ追加で許可するタグ・属性 */ +export const mathSanitizeConfig = { + ADD_TAGS: ['semantics', 'annotation'], + ADD_ATTR: ['encoding'], +}