改进 Markdown 渲染部分

This commit is contained in:
ivamp 2024-10-16 20:12:51 +08:00
parent 66290ea1d0
commit 5e5f7cd76e
12 changed files with 1813 additions and 54 deletions

View File

@ -11,8 +11,8 @@
"gen": "openapi-generator-cli generate -i ./api/swagger.yaml -g typescript-axios -o ./src/api"
},
"dependencies": {
"@iktakahiro/markdown-it-katex": "^4.0.1",
"@notable/html2markdown": "^2.0.3",
"@traptitech/markdown-it-katex": "^3.6.0",
"animate.css": "^4.1.1",
"axios": "^1.7.7",
"highlight.js": "^11.10.0",
@ -48,6 +48,7 @@
"naive-ui": "^2.39.0",
"postcss": "^8.4.45",
"prettier": "^3.3.3",
"sass-embedded": "^1.79.5",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.2",
"unplugin-auto-import": "^0.18.2",

1
src/components.d.ts vendored
View File

@ -17,6 +17,7 @@ declare module 'vue' {
LeftSettings: typeof import('./components/settings/LeftSettings.vue')['default']
LibrarySettings: typeof import('./components/settings/LibrarySettings.vue')['default']
Lottie: typeof import('./components/Lottie.vue')['default']
Markdown: typeof import('./components/markdown/index.vue')['default']
Mask: typeof import('./components/Mask.vue')['default']
MemorySettings: typeof import('./components/settings/MemorySettings.vue')['default']
Menu: typeof import('./components/Menu.vue')['default']

View File

@ -35,7 +35,14 @@
</div>
</n-flex>
</div>
<div v-else-if="(message.role === 'user' || message.role === 'user_hide' || message.role === 'user_later') && message.content">
<div
v-else-if="
(message.role === 'user' ||
message.role === 'user_hide' ||
message.role === 'user_later') &&
message.content
"
>
<!-- 用户消息 -->
<n-flex justify="end">
<div class="flex items-center flex-nowrap">
@ -51,11 +58,14 @@
{{ userStore.user.name }}
</n-divider>
</div>
<div
<!-- <div
v-if="mdInited"
class="break-all break-words markdown-body"
v-html="mdIt.render(message.content)"
></div>
></div> -->
<div>
<Markdown :typing="false" :content="message.content" />
</div>
</div>
<div class="relative h-full">
@ -103,11 +113,10 @@
{{ message.assistant?.name }}
</n-divider>
</div>
<div
v-if="mdInited"
class="break-all break-words markdown-body"
v-html="mdIt.render(message.content)"
></div>
<div>
<Markdown :typing="false" :content="message.content" />
</div>
</div>
<!-- <div v-html="mdIt.render(message.content)"></div> -->
@ -126,50 +135,9 @@ import { Ref } from "vue";
import { EntityChatMessage } from "@/api";
import { useUserStore } from "@/stores/user";
import leaflowPng from "@/assets/images/leaflow.png";
import markdownKatex from "@traptitech/markdown-it-katex";
import markdownIt from "markdown-it";
// highlightjs
import hljs from "highlight.js";
import Markdown from "@/components/markdown/index.vue";
import config from "@/config/config";
const mdIt = markdownIt();
const mdInited = ref(true);
const unsupportedLanguages = ["assembly", "blade", "vue"];
mdIt.options.highlight = function (str: string, lang: string) {
// TODO:
lang = "text"
if (!lang || unsupportedLanguages.includes(lang)) {
// return str;
lang = "text"
}
return hljs.highlight(str, { language: lang }).value;
};
mdIt.use(markdownKatex, {
throwOnError: false,
errorColor: "#cc0000",
output: "html",
});
// async function initMD() {
// mdIt.use(
// await Shiki({
// themes: {
// light: "vitesse-light",
// dark: "vitesse-dark",
// },
// })
// );
// mdIt.use(markdownKatex, {
// throwOnError: false,
// errorColor: "#cc0000",
// output: "html",
// });
// mdInited.value = true;
// }
const userStore = useUserStore();
const props = defineProps({
@ -182,7 +150,4 @@ const props = defineProps({
const chat_messages = toRef(props, "chat_messages") as Ref<EntityChatMessage[]>;
const fileBaseUrl = config.backend + "/api/v1/files";
onMounted(() => {
// initMD();
});
</script>

View File

@ -0,0 +1,17 @@
import type { PluginWithOptions } from 'markdown-it'
export interface APluginOptions {}
export const aPlugin: PluginWithOptions<APluginOptions> = (
md,
options = {}
): void => {
md.renderer.rules.link_open = (tokens, idx, options, env, slf) => {
const aIndex = tokens[idx].attrIndex('href')
if (aIndex !== -1) {
tokens[idx].attrs.push(['target', '_blank'])
}
return slf.renderToken(tokens, idx, options)
}
}

View File

@ -0,0 +1,52 @@
import type { PluginWithOptions } from 'markdown-it'
import { resolveLanguage } from './resolveLanguage.js'
import { resolveLineNumbers } from './resolveLineNumbers.js'
export interface CodePluginOptions {
lineNumbers?: boolean | number
}
export const codePlugin: PluginWithOptions<CodePluginOptions> = (
md,
{ lineNumbers = true } = {}
): void => {
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
const language = resolveLanguage(info)
const languageClass = `${options.langPrefix}${language.name}`
const code =
options.highlight?.(token.content, language.name, '') ||
md.utils.escapeHtml(token.content)
token.attrJoin('class', languageClass)
let result = code.startsWith('<pre')
? code
: `<pre${slf.renderAttrs(token)}><code>${code}</code></pre>`
result = `<div data-ext="${language.ext}" v-pre class="code-copy-line">
<div class="code-copy-btn"></div>
</div>${result}`
const lines = code.split('\n').slice(0, -1)
const useLineNumbers =
resolveLineNumbers(info) ??
(typeof lineNumbers === 'number'
? lines.length >= lineNumbers
: lineNumbers)
if (useLineNumbers) {
const lineNumbersCode = lines
.map(() => `<div class="line-number"></div>`)
.join('')
result = `<div class="line-numbers" aria-hidden="true">${lineNumbersCode}</div>${result}`
}
result = `<div><div class="${languageClass}${
useLineNumbers ? ' line-numbers-mode' : ''
}"><div class="pre-code-scroll">${result}</div></div></div>`
return result
}
}

View File

@ -0,0 +1,105 @@
/**
* Language type for syntax highlight
*/
export interface HighlightLanguage {
/**
* Name of the language
*
* The name to be used for the class name,
* e.g. `class="language-typescript"`
*/
name: string
/**
* Extension of the language
*
* The file extension, which will be used for the
* class name, e.g. `class="ext-ts"`
*/
ext: string
/**
* Aliases that point to this language
*
* Do not conflict with other languages
*/
aliases: string[]
}
export const languageBash: HighlightLanguage = {
name: 'bash',
ext: 'sh',
aliases: ['bash', 'sh', 'shell', 'zsh']
}
export const languageCsharp: HighlightLanguage = {
name: 'csharp',
ext: 'cs',
aliases: ['cs', 'csharp']
}
export const languageDocker: HighlightLanguage = {
name: 'docker',
ext: 'docker',
aliases: ['docker', 'dockerfile']
}
export const languageFsharp: HighlightLanguage = {
name: 'fsharp',
ext: 'fs',
aliases: ['fs', 'fsharp']
}
export const languageJavascript: HighlightLanguage = {
name: 'javascript',
ext: 'js',
aliases: ['javascript', 'js']
}
export const languageKotlin: HighlightLanguage = {
name: 'kotlin',
ext: 'kt',
aliases: ['kotlin', 'kt']
}
export const languageMarkdown: HighlightLanguage = {
name: 'markdown',
ext: 'md',
aliases: ['markdown', 'md']
}
export const languagePython: HighlightLanguage = {
name: 'python',
ext: 'py',
aliases: ['py', 'python']
}
export const languageRuby: HighlightLanguage = {
name: 'ruby',
ext: 'rb',
aliases: ['rb', 'ruby']
}
export const languageRust: HighlightLanguage = {
name: 'rust',
ext: 'rs',
aliases: ['rs', 'rust']
}
export const languageStylus: HighlightLanguage = {
name: 'stylus',
ext: 'styl',
aliases: ['styl', 'stylus']
}
export const languageTypescript: HighlightLanguage = {
name: 'typescript',
ext: 'ts',
aliases: ['ts', 'typescript']
}
export const languageYaml: HighlightLanguage = {
name: 'yaml',
ext: 'yml',
aliases: ['yaml', 'yml']
}

View File

@ -0,0 +1,46 @@
import * as languages from './languages.js'
import type { HighlightLanguage } from './languages.js'
type LanguagesMap = Record<string, HighlightLanguage>
/**
* A key-value map to get language info from alias
*
* - key: alias
* - value: language
*/
let languagesMap: LanguagesMap
/**
* Lazy generate languages map
*/
const getLanguagesMap = (): LanguagesMap => {
if (!languagesMap) {
languagesMap = Object.values(languages).reduce((result, item) => {
item.aliases.forEach((alias) => {
result[alias] = item
})
return result
}, {} as LanguagesMap)
}
return languagesMap
}
/**
* Resolve language for highlight from token info
*/
export const resolveLanguage = (info: string): HighlightLanguage => {
// get user-defined language alias
const alias = info.match(/^([^ :[{]+)/)?.[1] || ''
// if the alias does not have a match in the map
// fallback to the alias itself
return (
getLanguagesMap()[alias] ?? {
name: alias,
ext: alias,
aliases: [alias]
}
)
}

View File

@ -0,0 +1,14 @@
/**
* Resolve the `:line-numbers` / `:no-line-numbers` mark from token info
*/
export const resolveLineNumbers = (info: string): boolean | null => {
if (/:line-numbers\b/.test(info)) {
return true
}
if (/:no-line-numbers\b/.test(info)) {
return false
}
return null
}

View File

@ -0,0 +1,76 @@
import type { PluginWithOptions } from 'markdown-it'
const defaultMarker = '#'
interface CustomLinkPluginOptions {
marker: string
}
export const customLinkPlugin: PluginWithOptions<
Partial<CustomLinkPluginOptions>
> = (md, options = {}): void => {
const marker = options.marker || defaultMarker
md.block.ruler.before(
'paragraph',
'custom_link',
function (state, startLine, endLine, silent) {
const pos = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
if (pos >= max) {
return false
}
if (silent) {
return true
}
const text = state.src.substring(pos, max)
const start = text.indexOf(marker)
const end = text.lastIndexOf(marker)
if (start < 0 || end < 0 || start == end) {
return false
}
//#...#规则前面的内容
const startContent = text.substring(0, start)
//#...#规则后面的内容
const endContent = text.substring(end + 1)
//#...#规则中间的内容
const content = text.substring(start + 1, end)
//插入<div>
const token_div_o = state.push('div_open', 'div', 1)
token_div_o.map = [startLine, state.line]
//插入#...#规则前面的内容
const token_s = state.push('inline', '', 0)
token_s.content = startContent
token_s.children = []
//插入<a class="markdown-custom-link">
const token_a_o = state.push('link_open', 'a', 1)
token_a_o.attrs = [['class', 'markdown-custom-link']]
token_a_o.map = [startLine, state.line]
//插入#...#规则中间的内容
const token = state.push('inline', '', 0)
token.content = content
token.children = []
//闭合a标签
state.push('link_close', 'a', -1)
const token_e = state.push('inline', '', 0)
//插入#...#规则后面的内容
token_e.content = endContent
token_e.children = []
//闭合div标签
state.push('div_close', 'div', -1)
state.line = startLine + 1
return true
},
{
alt: ['paragraph', 'reference', 'blockquote']
}
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,168 @@
<template>
<div class="markdown-it-container">
<div
ref="markdownBodyRef"
class="markdown-body"
@click="handleClick($event)"
v-html="result"
/>
<div v-if="typing" class="markdown-typing" :style="cursorPosition"/>
</div>
</template>
<script setup lang="ts">
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
// import 'highlight.js/styles/atom-one-dark.css'
// import './github-markdown.css'
// @ts-ignore
import markdownItMath from '@iktakahiro/markdown-it-katex'
import type {CodePluginOptions} from './codePlugin'
import {codePlugin} from './codePlugin'
import {customLinkPlugin} from './customLink'
import {aPlugin} from './aPlugin'
interface Options extends Partial<MarkdownIt.Options> {
lineNumbers?: boolean
}
const props = withDefaults(
defineProps<{
content: string
html?: boolean
breaks?: boolean
linkify?: boolean
typographer?: boolean
//
lineNumbers?: boolean
//
typing: boolean
}>(),
{
content: '',
html: true,
breaks: true,
typographer: true,
linkify: true,
lineNumbers: true,
typing: false
}
)
const emit = defineEmits<{
(event: 'click-custom-link', value: string): void
}>()
const result = ref('')
const markdownBodyRef = shallowRef<HTMLDivElement>()
const createMarkdown = (options: Options) => {
const md = new MarkdownIt({
...options,
langPrefix: 'language-',
highlight(str: any, lang: any) {
try {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(lang, str, true).value
}
return hljs.highlightAuto(str).value
} catch (error) {
return str
}
}
})
md.use<CodePluginOptions>(codePlugin, {
lineNumbers: options.lineNumbers
})
md.use(markdownItMath, {
output: 'mathml'
})
md.use(aPlugin)
md.use(customLinkPlugin)
return md
}
let md: MarkdownIt
const findLastTextNode = (parent: Node): Node | undefined => {
const children = parent.childNodes
for (let i = children.length - 1; i >= 0; i--) {
const node = children[i]
if (node.nodeType === Node.TEXT_NODE && /\S/.test(node.nodeValue!)) {
node.nodeValue?.replace(/\S+$/, '')
return node
} else if (node.nodeType === Node.ELEMENT_NODE) {
const lastNode = findLastTextNode(node)
if (lastNode) {
return lastNode
}
}
}
}
const cursorPosition = reactive({
top: 'auto',
left: 'auto'
})
const updateCursor = () => {
if (markdownBodyRef.value) {
const lastTextNode = findLastTextNode(markdownBodyRef.value)
const textNode = document.createTextNode('\u200B')
if (lastTextNode) {
lastTextNode.parentElement?.appendChild(textNode)
} else {
markdownBodyRef.value?.appendChild(textNode)
}
const range = document.createRange()
range.setStart(textNode, 0)
range.setEnd(textNode, 0)
const textRect = range.getBoundingClientRect()
const markdownBodyRect = markdownBodyRef.value?.getBoundingClientRect()
cursorPosition.left = `${textRect.left - markdownBodyRect.left}px`
cursorPosition.top = `${textRect.top - markdownBodyRect.top}px`
textNode.remove()
}
}
const preprocessContent = (content: string) => {
return content.replace(/\n(#.*#)/g, '\n\n$1')
}
watchEffect(() => {
md = createMarkdown({
html: props.html,
breaks: props.breaks,
typographer: props.typographer,
linkify: props.linkify,
lineNumbers: props.lineNumbers
})
result.value = md.render(preprocessContent(props.content))
})
watch(
() => props.content,
async (value) => {
result.value = md?.render(preprocessContent(value))
await nextTick()
updateCursor()
},
{
immediate: true
}
)
// const {copy} = useCopy()
const handleClick = async (e: any) => {
console.log(e.target)
const target: HTMLElement = e.target
if (target.className === 'code-copy-btn') {
const text = e.target.parentElement.nextElementSibling.textContent
// await copy(text)
// copy
}
if (target.className === 'markdown-custom-link') {
emit('click-custom-link', target.textContent!)
}
}
</script>
<style lang="scss">
@use 'markdown.scss';
</style>

View File

@ -0,0 +1,212 @@
$line-height: 1.375;
:root {
--code-bg-color: #35373f;
--code-hl-bg-color: rgba(0, 0, 0, 0.66);
--code-ln-color: #6e6e7f;
--code-ln-wrapper-width: 3.5rem;
}
@keyframes blink {
from,
to {
opacity: 0;
}
50% {
opacity: 1;
}
}
.markdown-it-container {
.markdown-body {
background-color: transparent;
font-size: 15px;
.markdown-custom-link {
color: var(--el-color-primary);
cursor: pointer;
}
}
position: relative;
.markdown-typing {
position: absolute;
display: inline-block;
content: '';
width: 5px;
height: 14px;
transform: translate(4px, 2px) scaleY(1.3);
color: #1a202c;
background-color: currentColor;
animation: blink 0.6s infinite;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
code {
padding: 0.25rem;
margin: 0;
font-size: 0.85em;
border-radius: 3px;
}
code[class*='language-'],
pre[class*='language-'] {
color: #ccc;
background: none;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*='language-'] {
padding: 20px 0;
margin: 0;
overflow: auto;
}
:not(pre) > code[class*='language-'],
pre[class*='language-'] {
font-size: 0.85em;
background: #35373f;
}
/* Inline code */
:not(pre) > code[class*='language-'] {
border-radius: 0.3em;
white-space: normal;
}
pre,
pre[class*='language-'] {
line-height: $line-height;
border-radius: 6px;
overflow: visible;
display: inline-block;
padding: 20px;
code {
color: #fff;
padding: 0;
background-color: transparent !important;
border-radius: 0;
overflow-wrap: unset;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
}
}
div[class*='language-'] {
position: relative;
background-color: var(--code-bg-color);
border-radius: 6px;
padding-top: 32px;
margin: 0.85rem 0;
overflow: hidden;
.code-copy-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: #595b63;
color: #e0e0e0;
height: 32px;
line-height: 32px;
font-size: 12px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
padding: 0 12px;
&::before {
content: attr(data-ext);
position: absolute;
z-index: 3;
top: 0;
font-size: 0.75rem;
color: #e0e0e0;
}
.code-copy-btn {
position: absolute;
z-index: 3;
top: 0;
right: 1rem;
font-size: 0.75rem;
color: #e0e0e0;
cursor: pointer;
}
}
pre,
pre[class*='language-'] {
// force override the background color to be compatible with shiki
background: transparent !important;
position: relative;
z-index: 1;
}
.pre-code-scroll {
overflow: auto;
}
&:not(.line-numbers-mode) {
.line-numbers {
display: none;
}
}
&.line-numbers-mode {
padding-left: var(--code-ln-wrapper-width);
pre {
vertical-align: middle;
padding: 20px 20px 20px 0;
}
.line-numbers {
left: 0;
position: absolute;
top: 0;
width: var(--code-ln-wrapper-width);
text-align: center;
color: var(--code-ln-color);
padding-top: 52px;
line-height: $line-height;
counter-reset: line-number;
.line-number {
position: relative;
z-index: 3;
user-select: none;
height: #{$line-height - 0.2}em;
&::before {
display: block;
counter-increment: line-number;
content: counter(line-number);
font-size: 0.8em;
height: 100%;
}
}
}
// &::after {
// content: "";
// position: absolute;
// top: 0;
// left: 0;
// width: var(--code-ln-wrapper-width);
// height: 100%;
// border-radius: 6px 0 0 6px;
// border-right: 1px solid var(--code-hl-bg-color);
// }
}
}
}