开发喵星球

Vue3实现chatGPT的光标效果(有惊喜)

Vue3实现chatGPT的光标效果(有惊喜)

这篇文章最精彩的并不是现实光标的效果而是对于代码块的处理,话不多说直接开始正文。

代码高亮

众所周知chatGPT返回的文本格式是markdown格式的,所以我们要先将markdown格式转换为HTML文本,然后进行渲染。这里就可以使用 marked 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>

注释已经很详细了,但我还是再逐步梳理一下现实逻辑:

  1. props 定义

    • 通过 defineProps 定义了组件的 props,其中包括:
      • text:输入的 Markdown 文本,默认为空字符串。
      • showCursor:是否需要显示光标,默认为 false。
  2. 设置高亮配置

    • 使用 marked.setOptions 来设置 Markdown 转换为 HTML 时的配置。
    • 配置了代码高亮的处理方法,使用 highlight 函数来对代码进行高亮。
    • 其中 hljs 是代码高亮库,DOMPurify 用于清理 HTML,保证安全性。
  3. 计算属性 html

    • 使用 computed 创建计算属性 html,这个属性会根据输入的 Markdown 文本和 showCursor 的值来动态生成 HTML。
    • 在计算属性中,首先定义了一个函数 trans,用于将 Markdown 转换为 HTML,并通过 DOMPurify.sanitize 清理 HTML。
    • 如果 showCursor 为 true,则会判断当前 Markdown 文本是否处于代码块中,如果不在代码块中,则在文本最后添加一个光标元素 <span class="cursor"></span>,用来显示光标。
    • 最终返回转换后的 HTML。
  4. 模板

    • 在模板中使用 v-html 指令将计算属性 html 的值渲染为真实的 HTML 内容。
    • 通过设置 CSS 类 markdown 和 tailwindcss 类 leading-7 来控制文本样式,leading-7 控制行高为 1.75rem。

这样,该组件就实现了将 Markdown 转换为 HTML 并在页面上显示的功能,并且可以选择性地显示光标。

能够发现 markdown 显示和高亮还是十分容易的,但是这其中如何判断代码块的完结才是最难的点。

检测代码块是否完结

Markdown语法包含两种形式的代码块:一种是使用 code 标记的小代码块,另一种是使用 code 标记的大代码块。

我刚上手就想的是使用正则表达式来识别这些代码块,但发现情况比预期的要复杂得多。很难确定正确的正则表达式的编写方式,还需要考虑转义符的影响。我和chatGPT都没有写出来一个完美的正则,而且我想了一下就算成功编写了一组正则表达式,也很可能变得非常复杂,难以维护。

然后就开始了网上百度之旅,历经磨难之后终于发现了一个叫有限元状态机解析 html 文本的东西。

image-20240322204159336

image-20240322204329115

经过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。

下面是代码的主要思路和实现步骤:

  1. 定义状态枚举:首先定义了一个状态枚举 States,其中包含了三种状态:text(文本状态)、codeStartSm(小代码块状态)、codeStartBig(大代码块状态)。
  2. 编写函数 isInCode:该函数接受一个字符串参数 text,表示待检查的 Markdown 文本。函数会使用一个状态机来扫描文本并判断是否存在未闭合的代码块。具体步骤如下:

    • 初始化状态变量 stateStates.text,表示开始时处于文本状态。
    • 将源文本赋值给变量 source,并设置变量 inStarttrue,表示初始时处于文本开始状态。
    • 进入循环,遍历源文本的每个字符,直到文本被解析消费完。
    • 根据当前状态 state 使用 switch 语句进行不同的处理:
      • 若当前状态为 States.text,则根据源文本的开头字符进行判断:
        • 如果以 \n\“`开头,则表示遇到了大代码块,状态转移到States.codeStartBig`。
        • 如果遇到转义符 \,则跳过下一个字符。
        • 如果以 ` 开头,则表示遇到了小代码块,状态转移到 States.codeStartSm
        • 其他情况,直接消费当前字符。
      • 若当前状态为 States.codeStartSm,则判断当前字符是否为 `,若是,则表示小代码块结束,状态转移到 States.text
      • 若当前状态为 States.codeStartBig,则判断源文本是否以 \n\开头,若是,则表示大代码块结束,状态转移到States.text`。
    • 最后,返回 state !== States.text,表示是否存在未闭合的代码块。
  3. 导出函数:将 isInCode 函数作为默认导出,使其可以在其他地方使用。

通过这段代码,我们可以很方便地检查给定的 Markdown 文本是否存在未闭合的代码块,这在处理 Markdown 渲染等任务中非常有用。

效果

总结

至此,就已经实现了一个 chatgpt 消息渲染了。后续我还会分享GPT的流式输出,想看的话记得关注公众号。

   
分类:玩技术 作者:荡荡, 浩浩 发表于:2024-03-22 20:56:10 阅读量:274
<<   >>


powered by kaifamiao