这篇文章最精彩的并不是现实光标的效果而是对于代码块的处理,话不多说直接开始正文。
众所周知chatGPT返回的文本格式是markdown格式的,所以我们要先将markdown格式转换为HTML文本,然后进行渲染。这里就可以使用 marked
和 highlight
。
marked
用来将markdwon 转为 html。highlight
用来处理代码块高亮。先看代码再讲逻辑:
MarkdownRender.vue
<script setup>
import {computed} from 'vue';
import DOMPurify from 'dompurify';
import {marked} from 'marked';
import hljs from '//cdn.staticfile.org/highlight.js/11.7.0/es/highlight.min.js';
import mdInCode from "@/utils/mdInCode"; // 用于判断是否显示光标
const props = defineProps({
// 输入的 markdown 文本
text: {
type: String,
default: ""
},
// 是否需要显示光标?比如在消息流结束后是不需要显示光标的
showCursor: {
type: Boolean,
default: false
}
})
// 配置高亮
marked.setOptions({
highlight: function (code, lang) {
try {
if (lang) {
return hljs.highlight(code, {language: lang}).value
} else {
return hljs.highlightAuto(code).value
}
} catch (error) {
return code
}
},
gfmtrue: true,
breaks: true
})
// 计算最终要显示的 html 文本
const html = computed(() => {
// 将 markdown 转为 html
function trans(text) {
return DOMPurify.sanitize(marked.parse(text));
}
// 光标元素,可以用 css 美化成你想要的样子
const cursor = '<span class="cursor"></span>';
if (props.showCursor) {
// 判断 AI 正在回的消息是否有未闭合的代码块。
const inCode = mdInCode(props.text)
if (inCode) {
// 有未闭合的代码块,不显示光标
return trans(props.text);
} else {
// 没有未闭合的代码块,将光标元素追加到最后。
return trans(props.text + cursor);
}
} else {
// 父组件明确不显示光标
return trans(props.text);
}
})
</script>
<template>
<!-- tailwindcss:leading-7 控制行高为1.75rem -->
<div v-html="html" class="markdown leading-7">
</div>
</template>
<style lang="postcss">
/** 设置代码块样式 **/
.markdown pre {
@apply bg-[#282c34] p-4 mt-4 rounded-md text-white w-full overflow-x-auto;
}
.markdown code {
width: 100%;
}
/** 控制段落间的上下边距 **/
.markdown p {
margin: 1.25rem 0;
}
.markdown p:first-child {
margin-top: 0;
}
/** 小代码块样式,对应 markdown 的 `code` **/
.markdown :not(pre) > code {
@apply bg-[#282c34] px-1 py-[2px] text-[#e06c75] rounded-md;
}
/** 列表样式 **/
.markdown ol {
list-style-type: decimal;
padding-left: 40px;
}
.markdown ul {
list-style-type: disc;
padding-left: 40px;
}
/** 光标样式 **/
.markdown .cursor {
display: inline-block;
width: 2px;
height: 20px;
@apply bg-gray-800 dark:bg-gray-100;
animation: blink 1.2s step-end infinite;
margin-left: 2px;
vertical-align: sub;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
注释已经很详细了,但我还是再逐步梳理一下现实逻辑:
defineProps
定义了组件的 props,其中包括:
text
:输入的 Markdown 文本,默认为空字符串。showCursor
:是否需要显示光标,默认为 false。marked.setOptions
来设置 Markdown 转换为 HTML 时的配置。highlight
函数来对代码进行高亮。hljs
是代码高亮库,DOMPurify
用于清理 HTML,保证安全性。html
:
computed
创建计算属性 html
,这个属性会根据输入的 Markdown 文本和 showCursor
的值来动态生成 HTML。trans
,用于将 Markdown 转换为 HTML,并通过 DOMPurify.sanitize
清理 HTML。showCursor
为 true,则会判断当前 Markdown 文本是否处于代码块中,如果不在代码块中,则在文本最后添加一个光标元素 <span class="cursor"></span>
,用来显示光标。v-html
指令将计算属性 html
的值渲染为真实的 HTML 内容。markdown
和 tailwindcss 类 leading-7
来控制文本样式,leading-7
控制行高为 1.75rem。这样,该组件就实现了将 Markdown 转换为 HTML 并在页面上显示的功能,并且可以选择性地显示光标。
能够发现 markdown 显示和高亮还是十分容易的,但是这其中如何判断代码块的完结才是最难的点。
Markdown语法包含两种形式的代码块:一种是使用 code
标记的小代码块,另一种是使用 code
标记的大代码块。
我刚上手就想的是使用正则表达式来识别这些代码块,但发现情况比预期的要复杂得多。很难确定正确的正则表达式的编写方式,还需要考虑转义符的影响。我和chatGPT都没有写出来一个完美的正则,而且我想了一下就算成功编写了一组正则表达式,也很可能变得非常复杂,难以维护。
然后就开始了网上百度之旅,历经磨难之后终于发现了一个叫有限元状态机解析 html 文本的东西。
经过GPT的一番解释看懂了吗?其实可以理解为一个简单的状态转换流程:
现在就可以一起来看代码了,看完代码就懂精彩之处了
const States = {
text: 0, // 文本状态
codeStartSm: 1, // 小代码块状态
codeStartBig: 2, // 大代码块状态
}
/**
* 判断 markdown 文本中是否有未闭合的代码块
* @param text
* @returns {boolean}
*/
function isInCode(text) {
let state = States.text
let source = text
let inStart = true // 是否处于文本开始状态,即还没有消费过文本
while (source) { // 当文本被解析消费完后,就是个空字符串了,就能跳出循环
let char = source.charAt(0) // 取第 0 个字
switch (state) {
case States.text:
if (/^\n?```/.test(source)) {
// 以 ```或者 \n```开头。表示大代码块开始。
// 一般情况下,代码块前面都需要换行。但是如果是在文本的开头,就不需要换行。
if (inStart || source.startsWith('\n')) {
state = States.codeStartBig
}
source = source.replace(/^\n?```/, '')
} else if (char === '\\') {
// 遇到转义符,跳过下一个字符
source = source.slice(2)
} else if (char === '`') {
// 以 ` 开头。表示小代码块开始。
state = States.codeStartSm
source = source.slice(1)
} else {
// 其他情况,直接消费当前字符
source = source.slice(1)
}
inStart = false
break
case States.codeStartSm:
if (char === '`') {
// 遇到第二个 `,表示代码块结束
state = States.text
source = source.slice(1)
} else if (char === '\\') {
// 遇到转义符,跳过下一个字符
source = source.slice(2)
} else {
// 其他情况,直接消费当前字符
source = source.slice(1)
}
break
case States.codeStartBig:
if (/^\n```/.test(source)) {
// 遇到第二个 ```,表示代码块结束
state = States.text
source = source.replace(/^\n```/, '')
} else {
// 其他情况,直接消费当前字符
source = source.slice(1)
}
break
}
}
return state !== States.text
}
export default isInCode
现实逻辑:
该函数会扫描文本,检查是否存在未闭合的代码块,如果存在则返回 true,否则返回 false。
下面是代码的主要思路和实现步骤:
States
,其中包含了三种状态:text
(文本状态)、codeStartSm
(小代码块状态)、codeStartBig
(大代码块状态)。
isInCode
:该函数接受一个字符串参数 text
,表示待检查的 Markdown 文本。函数会使用一个状态机来扫描文本并判断是否存在未闭合的代码块。具体步骤如下:
state
为 States.text
,表示开始时处于文本状态。source
,并设置变量 inStart
为 true
,表示初始时处于文本开始状态。state
使用 switch
语句进行不同的处理:
States.text
,则根据源文本的开头字符进行判断:
\n\
“或
“`开头,则表示遇到了大代码块,状态转移到
States.codeStartBig`。\
,则跳过下一个字符。States.codeStartSm
。States.codeStartSm
,则判断当前字符是否为 `,若是,则表示小代码块结束,状态转移到 States.text
。States.codeStartBig
,则判断源文本是否以 \n\
“开头,若是,则表示大代码块结束,状态转移到
States.text`。state !== States.text
,表示是否存在未闭合的代码块。isInCode
函数作为默认导出,使其可以在其他地方使用。
通过这段代码,我们可以很方便地检查给定的 Markdown 文本是否存在未闭合的代码块,这在处理 Markdown 渲染等任务中非常有用。
至此,就已经实现了一个 chatgpt 消息渲染了。后续我还会分享GPT的流式输出,想看的话记得关注公众号。
powered by kaifamiao