在做 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 转为 html function trans(text) { return DOMPurify.sanitize(marked.parse(text)); } // 光标元素,可以用 css 美化成你想要的样子 const cursor = '<span></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> /** 设置代码块样式 **/ .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 简单介绍一下有限元状态机:
请站在web前端的视角下进行介绍
回到正题,我可以一点一点的从头开始去解析 markdown 文本。想象这么一个简单的状态转换流程:
- 初始状态为文本状态。
- 遇到代码块标记,文本状态转换到代码块开始状态。
- 再次遇到代码块标记,从代码块开始状态转换到文本状态。
不过现实要更复杂一点,我们有小代码块和大代码块。有限元状态机的妙处就在这里,当处在小代码块状态的时候,我们不需要操心大代码块和正常文本的事,他的下一个状态只能是遇到小代码块的闭合标签,进入文本状态。
理解了这些,再来看我的源码,才会发现他的精妙。
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
到这里,就已经实现了一个 chatgpt 消息渲染了。喜欢的话点个赞吧!谢谢!
以上就是实例详解vue3实现chatgpt的打字机效果的详细内容,更多请关注Work网其它相关文章!