在做 chatgpt 镜像站的时候,发现有些镜像站是没做打字机的光标效果的,就只是文字输出,是他们不想做吗?反正我想做。于是我仔细研究了一下,实现了打字机效果加光标的效果,现在分享一下我的解决方案以及效果图
共识
首先要明确一点,chatgpt 返回的文本格式是 markdown 的,最基本的渲染方式就是把 markdown 文本转换为 HTML 文本,然后 v-html
渲染即可。这里的转换和代码高亮以及防 XSS 攻击用到了下面三个依赖库:
- marked 将markdwon 转为 html
- highlight 处理代码高亮
- dompurify 防止 XSS 攻击
同时我们是可以在 markdown 中写 html 元素的,这意味着我们可以直接把光标元素放到最后!
将 markdown 转为 html 并处理代码高亮
先贴代码
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 转为 htmlfunction 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>
可以发现最基本的 markdown 显示还是挺简单的,话就不多说了,都在注释里。
我想你也许对判断消息中的代码块是否未闭合更感兴趣,那么就继续看下去吧!
代码块是否未闭合
markdown 有两种代码块,一种是 `code` ,另一种是 ``` code ```,我叫他小代码块和大代码块。
一开始我是想用正则去判断的,但是奈何有点复杂,我实在想不出应该如何去编写正则,让 chatgpt 写的正则也会判断失败,而且还要考虑到转义符,就算写出了正则,估计也会很复杂和难以维护。
经过短暂的苦思冥想后,我想到了之前在 《Vue.js设计与实现》 中看到的用有限元状态机解析 html 文本的方案。
这里有请 chatgpt 简单介绍一下有限元状态机:
有限元状态机(finite element state machine)是一种基于状态机的建模技术,它可以将系统的行为描述为一组状态和状态之间的转换。每个状态可以对应系统内的一种状态,而状态之间的转换则表示系统内部的转换过程。有限元状态机还可以用于系统的可靠性分析和测试,同时也可以作为软件工程中的一种建模工具,帮助代码实现、测试和调试等方面。
在实际应用中,有限元状态机通常是通过自动化工具生成,并且可以用于控制系统、网络通信协议、软件接口等方面的设计和测试。由于有限元状态机具有自动化生成和完备性检查等优点,因此它已经成为了一种广泛应用的建模与测试工具。
请站在web前端的视角下进行介绍
从前端开发的角度来看,有限元状态机可以用于构建复杂的交互式Web应用程序。在Web应用程序中,有限元状态机可以描述用户界面的不同状态(例如,登录、注册、未登录等),以及状态之间的转换(例如,从登录状态转换到注册状态)。
通常情况下,前端工程师使用JavaScript实现有限元状态机,包括定义状态、定义状态之间的转换规则、处理状态转换时的行为等。使用有限元状态机可以帮助前端工程师更好地掌控应用程序的状态,从而减少代码中的复杂条件分支和无法预测的行为。同时,有限元状态机也可以帮助前端团队共同理解应用程序的状态和转换规则,从而更好地协作开发和维护Web应用程序。
总之,有限元状态机是一种非常有用的前端开发技术,可以帮助前端工程师更好地构建和管理Web应用程序的状态和行为,提高应用程序的可靠性和用户体验。
回到正题,我可以一点一点的从头开始去解析 markdown 文本。想象这么一个简单的状态转换流程:
- 初始状态为文本状态。
- 遇到代码块标记,文本状态转换到代码块开始状态。
- 再次遇到代码块标记,从代码块开始状态转换到文本状态。
不过现实要更复杂一点,我们有小代码块和大代码块。有限元状态机的妙处就在这里,当处在小代码块状态的时候,我们不需要操心大代码块和正常文本的事,他的下一个状态只能是遇到小代码块的闭合标签,进入文本状态。
理解了这些,再来看我的源码,才会发现他的精妙。
const States = {text: 0, // 文本状态codeStartSm: 1, // 小代码块状态codeStartBig: 2, // 大代码块状态
}/*** 判断 markdown 文本中是否有未闭合的代码块* @param text* @returns {boolean}*/
function isInCode(text) {let state = States.textlet source = textlet 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.codeStartSmsource = source.slice(1)} else {// 其他情况,直接消费当前字符source = source.slice(1)}inStart = falsebreakcase States.codeStartSm:if (char === '`') {// 遇到第二个 `,表示代码块结束state = States.textsource = source.slice(1)} else if (char === '\\') {// 遇到转义符,跳过下一个字符source = source.slice(2)} else {// 其他情况,直接消费当前字符source = source.slice(1)}breakcase States.codeStartBig:if (/^\n```/.test(source)) {// 遇到第二个 ```,表示代码块结束state = States.textsource = source.replace(/^\n```/, '')} else {// 其他情况,直接消费当前字符source = source.slice(1)}break}}return state !== States.text
}export default isInCode
到这里,就已经实现了一个 chatgpt 消息渲染了。喜欢的话点个赞吧!谢谢!