技术博客

越是喧闹,越是孤独。越是寂寞,越是丰富
The more noisy, the more lonely. The more lonely, the more rich

越是喧闹,越是孤独。越是寂寞,越是丰富
The more noisy, the more lonely. The more lonely, the more rich

技术博客


从零到一开发ChatGPT Lite - 前端篇

2023-04-27 Mendel
前端ChatGPT


本文为ChatGPT Lite开发系列的第二篇——前端篇,关于后端开发部分,请移步《从零到一开发ChatGPT Lite - 后端篇》




01

整体结构


本应用的前端部分采用的是Vue+Bootstrap技术栈,考虑到应用的功能相对简单,为了调试和部署方便,没有使用Webpack等构建工具,直接用flask的render_template进行模板渲染,没有使用前后端分离。另外,还引入了jQuery(Bootstrap依赖)、Marked.js、Highlight.js等库来实现丰富的前端功能和展示效果。




02


显示Markdown内容


目前Chat API返回的内容,是markdown格式的文本,如果直接展示在html上并不好看,需要用一个markdown到html的转换工具——markedjs (https://github.com/markedjs/marked)。用法很简单,入markdown格式的文本,即可返回html格式的字符串。在本应用中,采用的是在xhr.onprogress事件中,将当前返回的流式内容直接处理为html格式:

xhr.onprogress = (e) => {    let currentContent = xhr.responseText    this.currentMessage = marked.parse(currentContent)}

并将currentMessage绑定到DOM节点上:

<div v-html="currentMessage"></div>

正常情况下,就可以实现markdown格式在页面上的展示了。但是要注意的是,如果用了v-html指令,就需要留个心眼,确保html内容的安全性。在Vue.js官方文档上也着重强调了这一点:


接下来,我做了一个测试,果然有问题:


因为是以流式返回,所以会闪烁出现 "div" "</"  等字样几次,然后就消失了,最后只展示出了数字。这既不是想要的结果,也会带来安全隐患。解决办法就是,给marked.js设置一个渲染器renderer,将html内容(主要是 < 和 >)进行escape,这样即可确保安全性:

marked.use({    renderer: {        html(text) {            return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')        }    }})


增加上述代码后,返回的内容就正常了:


关于markedjs的renderer的详细介绍或其他功能,可参见其文档(链接见附录)。



03


代码区语法高亮


ChatGPT的强项之一就是代码编写能力很强,虽然有些大段代码原封不动拷过来用可能会有bug,但是它提供的思路还是很清晰的,也能编写注释,对程序员的开发效率有很大提升。


目前API返回的内容中,代码段的markdown格式为:

```lang// code here```

经过markedjs转成html后的格式如下:

<pre>  <code class="language-lang">    <!-- code here -->  </code></pre>

其中lang表示具体的编程语言的名称。如果代码区不是编程语言,则code的class属性可能就没有。虽然转换成html格式后,能在页面显示出代码区的轮廓,但是也只是清一色冷冰冰的代码放在那,并不生动。目前ChatGPT有代码语法高亮的功能,并且也在顶部展示当前编程语言及复制按钮,那么我们也来实现这样的效果吧。


首先,代码语法高亮,可直接使用Highlight.js这个库(链接见附录)。参考文档,我们可以对当前消息中的所有 pre code 节点进行hightlight渲染:

$('.message-content:last').find('pre code').each((index, node) => {    hljs.highlightElement(node);}

其中,.message-content:last 选择器表示的是当前页面上最后一条消息的div节点。我们将此节点下的所有经过markedjs处理过之后的 <pre><code>这样父子关系节点,调用highlightElement进行处理。这样就可以实现代码区的语法高亮了,色彩顿时丰富了很多。


需要注意的是,代码语法高亮操作是对DOM的操作,而这些DOM节点是由vue负责生成的,因此需要在vue渲染完成之后再去允许jQuery对其操作。所以以上代码需要放在 Vue.$nextTick 回调函数中执行。我目前是在xhr.onload事件中,在完整消息被添加到消息列表后的nextTick时机执行语法高亮,并没有在流式返回时实时高亮。


接着,我们要给代码区增加一个header,展示语言名称和复制按钮。想要实现的html大致效果为:

<pre>  <code class="language-python">    <div class="language-toolbar">      <span>python</span>      <button>Copy</button>    </div>    <!-- code here -->  </code></pre>

那么我们基于这个效果,继续编写代码:

$('.message-content:last').find('pre code').each((index, node) => {    hljs.highlightElement(node);
// 获得语言 let language = 'code' let className = [...node.classList].find(className => className.startsWith('language-')) if (className) { language = className.substring('language-'.length) }
// 添加 language-toolbar header let toolbar = document.createElement('div') toolbar.classList.add('language-toolbar') toolbar.innerHTML = ` <span class="mr-5">${language}</span> <button class="btn btn-outline-light copy-code-btn" type="button">Copy</button> ` // 添加到代码区顶端 node.insertBefore(toolbar, node.firstChild)
// 去除padding-top node.classList.add('pt-0')})

由于bootstrap和highlight.js都会影响<code>的样式,所以在添加 header时,注意调整下样式使其展示美观。其中 header的样式为:

.language-toolbar {    margin: 0 -1rem 1rem -1rem;    padding: 0.5rem 1rem;    background: #333;    color: #fff;    display: flex;    justify-content: space-between;}

这样,header工具条就展示出来了。展示效果如下,感觉还不错。


接下来,该实现点击右上角Copy按钮时的复制功能了。我们可以对每个copy按钮都绑定click事件,也可以在父节点绑定一个事件,通过事件委托(或事件代理,Event Delegation)的方式监听每一个按钮冒泡上来的事件。为了节省浏览器的资源,我们采用后者的方式来实现。

// 为全局的copy按钮创建事件代理$(this.$refs.mainContent).on('click', '.copy-code-btn', event => {    let btn = event.target    const range = document.createRange();    // 选中的起点为toolbar的下一个元素,即代码正文开始    range.setStart(btn.parentNode.nextSibling, 0)    // 选中的终点是代码正文最后一个元素+其长度,即选到最后    let lastChild = btn.parentNode.parentNode.lastChild    range.setEnd(lastChild, lastChild.length)
const selection = window.getSelection(); if (selection.rangeCount > 0) { selection.removeAllRanges(); } selection.addRange(range); document.execCommand('copy');
btn.innerHTML = 'Copied!' btn.setAttribute('disabled', 'disabled') setTimeout(() => { btn.innerHTML = 'Copy' btn.removeAttribute('disabled') }, 2000)})

由于header是<code>的第一个子节点,所以在选中时,需要从下一个元素开始,不然就把header中的文字也一并选中了。(这个复制功能的实现方式不一定是最优的方式,后面会寻求有没有更好的实现)


此代码在页面刚加载时执行一次即可(在Vue的mounted钩子中执行)。这样就实现了功能比较完整的代码区处理。



04


滚动条触底判断


当消息以流式返回不断地扩充消息div节点时,如果此时div发生了换行,那么要不要自动将滚动条滚到最底部以跟随展示当前消息的最后一行呢?还是保持当前视觉位置不变呢?


从用户体验角度来看,这应该取决于当前滚动条的位置(scrollTop)。如果当前就是在最底部,那么默认可以让其持续保持触底以始终展示出最新的消息内容。但如果在消息返回的过程中,用户向上滚动翻页了,那么就应该不触底,保持滚动条位置不变,以利于用户阅读之前的信息内容。那么如何实现这样的效果呢?


我们需要监听消息div的高度变化,当发生变化时,判断一下当前消息区域父节点的滚动条的位置,如果在底部,则自动触底(scrollIntoView,滚动到可视区),否则保持不变。这里采用了一个叫做ResizeObserver的技术(链接见附录),它能够对节点的宽度和高度进行监听,在它们发生变化时可触发用户自定义的回调函数。


ResizeObserver属于一个较新的API,但是主流浏览器已经支持,可以放心使用。如果考虑兼容性,可以在不支持ResizeObserver的环境下使用setInterval定期检测高度是否发生变化。


使用ResizeObserver主要分三步。第一步是创建回调函数并用于初始化Observer:

const handleHeightChange = (entries) => {    // ...}this.resizeObserver = new ResizeObserver(handleHeightChange)


第二步,设置要监听的节点:

this.resizeObserver.observe(targetDOMNode)


第三步,在任务完成后,删除监听,释放资源:

this.resizeObserver.disconnect()


主要的逻辑在 handleHeightChange 的回调如何编写。首先,我们目前只监听纵向滚动条,所以只需要关注height的变化。但是该回调在宽度和高度任何一个发生变化时都会触发,所以需要用一个额外的变量存储历史的height值以判断仅在高度变化时触发我们的逻辑。

const handleHeightChange = (entries) => {    let entry = entries[0]    const {height} = entry.contentRect;
// 检查高度是否发生了变化 if (height !== this.previousObservedHeight) {        // 业务逻辑 // 更新存储的高度以供下次比较 this.previousObservedHeight = height; }}


然后在height发生变化时,我们需要获取到以下几个值,来进行判断:


  • scrollTop:当前节点的滚动条已经滚过的距离

  • scrollHeight: 全内容高度,当前节点在不使用滚动条时的实际高度

  • visualHeight:可视高度,当前节点在页面上呈现的高度,需要通过getComputedStyle(dom).height获得,而不建议通过dom.style.height获得(如果节点具备flex伸缩性等高度可动态变化的场景)

  • deltaHeight:变化高度,被ResizeObserver监听到高度变化时,实际发生的高度变化量

  • bufferHeight:缓冲/容忍高度,当用户将滚动条从最底部细微向上滚动很少量的变化时(如5px/10px等),可认为仍然在底部,满足触底条件。这个值根据需求调整即可,这里我取的是24px(1倍的line-height,即一行文字的高度)。


在正常情况下,当scrollTop + visualHeight = scrollHeight时,我们可认为滚动条是在最底部的。再加上deltaHeight和bufferHeight两个变量后,判断逻辑变为:

if (scrollTop + visualHeight >=     scrollHeight - deltaHeight - bufferHeight) {    this.scrollIntoView()}

这样,就可以实现既允许用户实时看到最新的内容,也允许用户翻回去查看历史内容。




05


输入框体验优化


使用过ChatGPT的小伙伴们可能会发现,当用PC端访问且左侧展示出会话历史时,底部输入栏按回车即可发送消息,按Shift+Enter才执行换行。而在移动端访问或者直接将PC浏览器窗口缩窄后,输入栏按回车一律是换行,需要点击右侧的按钮才可实现发送。


这个属于响应式设计,我也参考着实现了自己的处理逻辑:当按下回车时,如果没有按Shift,且在PC端,则进行发送,其余情况均为换行。

<textarea @keypress="onKeyPress($event)"></textarea>
onTextAreaPress(event) { if (event.keyCode === 13 && !event.shiftKey && !('ontouchstart' in window)) { this.sendMessage(event) }}


另外,关于textarea也增加了一个动态效果,就是默认展示一行的高度,当输入文字变为两行时,自动扩高为两行的高度,最高不超过3行。那么如何实现呢?


先给textarea设置初始为1行,即rows=1,并设置下min-height和max-height,之后在input事件中动态设置height属性即可:

<textarea rows="1" style="min-height:38px; max-height:84px;"  @input="onTextAreaInput($event)"></textarea>
onTextAreaInput(event) { let textarea = event.target textarea.style.height = '38px'
if (textarea.scrollHeight > 38) { textarea.style.height = textarea.scrollHeight + 'px' }}



至此,关于ChatGPT Lite的前端和后端开发过程就写完了,希望能给大家带来帮助,将来我也会不断地进行优化并整理开发心得,欢迎大家持续关注!


点击“阅读原文”可立即访问ChatGPT Lite!



【附录链接】


1. Markedjs 文档 - renderer

https://marked.js.org/using_pro#renderer

2. Highlight.js 文档

https://highlightjs.org/usage/

3. ResizeObserver MDN 文档

https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver






相关文章