Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f9458170e | ||
|
|
4882d291be | ||
|
|
6f9fc10fa1 |
@@ -111,6 +111,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
### Markdown レンダリング
|
### Markdown レンダリング
|
||||||
|
|
||||||
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
|
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
|
||||||
|
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
|
||||||
|
|
||||||
### モデル切替
|
### モデル切替
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,13 @@ frontend/src/
|
|||||||
|
|
||||||
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
|
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
|
||||||
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
|
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
|
||||||
|
- 数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(`mathExtension` / `mathSanitizeConfig`)
|
||||||
|
- ブロック: 行頭の `$$ ... $$` と `\[ ... \]`。インライン: `$ ... $`、`\( ... \)`、行中の `$$ ... $$`
|
||||||
|
- `$ ... $` は誤爆防止のため「開き `$` の直後が空白でない」「中身に改行を含まない」「閉じ `$` の直後が数字でない」を満たすものだけを数式にする(`$100 から $200` のような金額表記は素通しする)。リテラルのドル記号は `\$` でエスケープできる
|
||||||
|
- インラインコード・コードフェンス内の `$` は marked のトークナイザ順により数式にならない
|
||||||
|
- 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(`throwOnError: false`)
|
||||||
|
- KaTeX の MathML 出力に含まれる `semantics` / `annotation` は DOMPurify の既定では許可されないため、`mathSanitizeConfig` で明示的に許可している
|
||||||
|
- ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される
|
||||||
|
|
||||||
### Vite プロキシ設定
|
### Vite プロキシ設定
|
||||||
|
|
||||||
|
|||||||
Generated
+26
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.1.0",
|
"dompurify": "^3.1.0",
|
||||||
|
"katex": "^0.16.47",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
@@ -1117,6 +1118,15 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1242,6 +1252,22 @@
|
|||||||
"he": "bin/he"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.1.0",
|
"dompurify": "^3.1.0",
|
||||||
|
"katex": "^0.16.47",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
import { mathExtension, mathSanitizeConfig } from '../utils/math'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
content: string
|
content: string
|
||||||
@@ -9,6 +11,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
marked.setOptions({ breaks: true, gfm: true })
|
marked.setOptions({ breaks: true, gfm: true })
|
||||||
|
marked.use(mathExtension)
|
||||||
|
|
||||||
function closeOpenFences(src: string): string {
|
function closeOpenFences(src: string): string {
|
||||||
const fenceCount = (src.match(/^```/gm) ?? []).length
|
const fenceCount = (src.match(/^```/gm) ?? []).length
|
||||||
@@ -18,7 +21,7 @@ function closeOpenFences(src: string): string {
|
|||||||
|
|
||||||
const html = computed(() => {
|
const html = computed(() => {
|
||||||
const raw = props.pending ? closeOpenFences(props.content) : props.content
|
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)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -104,6 +107,15 @@ const html = computed(() => {
|
|||||||
/* 水平線 */
|
/* 水平線 */
|
||||||
.md :deep(hr) { border-color: #333; margin: 10px 0; }
|
.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) { color: #60a5fa; text-decoration: none; }
|
||||||
.md :deep(a:hover) { text-decoration: underline; }
|
.md :deep(a:hover) { text-decoration: underline; }
|
||||||
|
|||||||
@@ -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, '>')
|
||||||
|
.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'],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user