本文介绍使用 ChatGPT API 实现类似官网打字机回复效果的思路和方法,并附核心模块代码示例(vue3 + TS)
- 调用流式接口、解析数据
- 渲染 markdown
- 代码块高亮
- 打字机效果 + 移动光标
- 其他 (一些思路)
准备工作
- 注册 OpenAI 账号
- 生成调用接口鉴权的 APIkey
- 一个不被墙的网络环境
这一部分方法很多, 也存在时效性的问题, 大家可以自行查找合适的方法
接口调用
API
URL : api.openai.com/v1/chat/com…
请求方法: POST
接口官方文档: platform.openai.com/docs/api-re…
简单介绍一下我们用到的几个关键参数
Header
参数 | 说明 | 值 |
---|---|---|
Content-Type | 内容类型 | application/json |
Authorization | 鉴权, 传获取的 KEY | Bearer 你的 KEY |
Body
参数 | 说明 | 值 |
---|---|---|
model | 使用的模型 | gpt-3.5-turbo |
messages | 对话内容:, role 可传'user'和'system', 通过这个字段可以实现上下文对话,或者预设对话的前文 | [{'role': 'user', content: '问题内容'}] |
strame | 是否流传输,如果是 false 将会一次性返回结果 | true |
发送请求
async fetch(messages: GptMsgs) {
return await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages,
stream: true
}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.key}`
}
})
}
将 stream 设置为 true, 接口会将结果作为 EventStream 的一系列事件(event)返回
这里用的是浏览器原生支持的 FetchAPI
axios 在 config 中设置 responseType: ‘stream’也能实现同样的效果
解析流式数据
- 从返回的 body 中通过 getReader 获取 reader
if (!res.body) return
// 从response中获取reader
const reader = res.body.getReader()
-
通过 while 循环 + reader.read() 获取每次传输的数据
通过控制台打印出来是这样的:
const { done, value } = await reader.read() console.log(value)
-
解析这些 Uint8Array 数据
-
用浏览器原生支持的 TextDecoder 将 buffer 解析成字符串,解析出來的格式如下图
-
使用正则表达式去匹配其中的 JSON 格式
const parsePack = (str: string) => { // 定义正则表达式匹配模式 const pattern = /data:\s*({.*?})\s*\n/g // 定义一个数组来存储所有匹 配到的 JSON 对象 const result = [] // 使用正则表达式匹配完整的 JSON 对象并解析它们 let match while ((match = pattern.exec(str)) !== null) { const jsonStr = match[1] try { const json = JSON.parse(jsonStr) result.push(json) } catch (e) { console.log(e) } } // 输出所有解析出的 JSON 对象 return result }
- 从每个解析出来的 JSON 数据结构中取出我们要的内容
// 解析出的结构 { "id": "chatcmpl-7KM1Fg6dyjS1u8ZynusrTT0N5y0Sj", "object": "chat.completion.chunk", "created": 1685085557, "model": "gpt-3.5-turbo-0301", "choices": [ { "delta": { "content": "助" }, "index": 0, "finish_reason": null } ] }
// 获取新增的字符 if (!json.choices || json.choices.length === 0) { return } const text = json.choices[0].delta.conten
-
在实际的封装中增加了一些钩子函数来拓展其他的功能,完整的代码如下
- onStart: 调用函数后请求发出前
- onCreated: 发出请求收到第一个回包后执行
- onPatch: 有新的内容更新时执行
- onDone: 传输结束时执行
export interface GptMsg {
role: string
content: string
}
export type GptMsgs = Array<GptMsg>
export class StreamGpt {
onStart: (prompt: string) => void
onCreated: () => void
onDone: () => void
onPatch: (text: string) => void
constructor(private key: string, options: {
onStart: (prompt: string) => void
onCreated: () => void
onDone: () => void
onPatch: (text: string) => void
}) {
const { onStart, onCreated, onDone, onPatch } = options
this.onStart = onStart
this.onCreated = onCreated
this.onPatch = onPatch
this.onDone = onDone
}
async stream(prompt: string, history: GptMsgs = []) {
let finish = false
let count = 0
// 触发onStart
this.onStart(prompt)
// 发起请求
const res = await this.fetch([...history, { 'role': 'user', content: prompt }])
if (!res.body) return
// 从response中获取reader
const reader = res.body.getReader()
const decoder: TextDecoder = new TextDecoder()
// 循环读取内容
while (!finish) {
const { done, value } = await reader.read()
// console.log(value)
if (done) {
finish = true
this.onDone()
break
}
count++
const jsonArray = parsePack(decoder.decode(value))
if (count === 1) {
this.onCreated()
}
jsonArray.forEach((json: any) => {
if (!json.choices || json.choices.length === 0) {
return
}
const text = json.choices[0].delta.content
this.onPatch(text)
})
}
}
async fetch(messages: GptMsgs) {
return await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages,
stream: true
}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.key}`
}
})
}
}
打字机效果
打字机队列
现在我们有了一个不断补充内容的字符串, 但在传输过程中每次停顿后会跳出一串内容然后又停顿一会, 阅读体验有些不流畅, 就像玩游戏时帧数低卡顿的感觉, 我们用一个队列让它逐字地展示出来, 并且根据传输速度控制输出的速度
- 队列提供入队和消费功能
- 定时器在时间间隔中输出字符
- 消费的间隔采用动态计算的方式,尽可能保证队列中的剩余字符能在两秒钟内消费完
- 结束时将剩余的字符一次性消费
// 打字机队列
export class Typewriter {
private queue: string[] = []
private consuming = false
private timmer: any
constructor(private onConsume: (str: string) => void) {
}
// 输出速度动态控制
dynamicSpeed() {
const speed = 2000 / this.queue.length
if (speed > 200) {
return 200
} else {
return speed
}
}
// 添加字符串到队列
add(str: string) {
if (!str) return
this.queue.push(...str.split(''))
}
// 消费
consume() {
if (this.queue.length > 0) {
const str = this.queue.shift()
str && this.onConsume(str)
}
}
// 消费下一个
next() {
this.consume()
// 根据队列中字符的数量来设置消耗每一帧的速度,用定时器消耗
this.timmer = setTimeout(() => {
this.consume()
if (this.consuming) {
this.next()
}
}, this.dynamicSpeed())
}
// 开始消费队列
start() {
this.consuming = true
this.next()
}
// 结束消费队列
done() {
this.consuming = false
clearTimeout(this.timmer)
// 把queue中剩下的字符一次性消费
this.onConsume(this.queue.join(''))
this.queue = []
}
}
在 VUE3 中使用
封装 hook
- 定义了 streamingText 来存储传输中的字符串
- streaming 标识当前是否在传输中
- msgList 保存历史的对话记录
- history 参数控制是否在每次请求时带上历史记录
- 注意: api 的接口 token 的上限为 4096,实际的话要判断是否超出长度而抛弃一些早前的记录
使用
渲染 Markdown
现在我们有了解析好的内容, 可以看到是 Markdown 的格式,我们要将它转换成 HTML,试了几个库后选择了 Markdown-it
- 安装 markdown-it
- 引入并初始化
- 解析 md 内容
代码块高亮
如果要让 markdown 中的代码呈现出代码块的样式并且对特定语言的语法进行高亮,我们要借助 hightlight.js 这个库
- 安装 highlight.js
npm install highlight.js
- 引入并初始化
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-dark-reasonable.css'
从 node_modules 中引入相应的样式文件, 在对应的文件夹下可以找到更多代码的样式主题,路径引入即可
-
在 markdown-it 提供的钩子函数中将代码部分用 hightlight.js 改写
同时在钩子函数给代码块插入一个 class 为 hl-code-header 的头部,来实现代码语言的显示
光标闪烁效果
在打字机输出文字时,给尾部加一个闪烁的光标
- 在解析出的 HTML 结构中,找到最后一个元素并在这个元素的尾部插入一个光标元素
- 给它加上闪烁动画
- 对一些特殊的标签特殊处理,例如代码块的 PRE 标签
- 对于深层嵌套的元素递归查找到最后一个元素
实时滚到到底部
通过 vue 的 watch 监听 streamingText 来判断是否新增了内容,让页面随着内容的输出滚动到底部,让阅读的体验更上一层楼
给大家推荐一个 VUE3 的 hooks 库 vueuse,功能十分齐全,这里滚动的逻辑就用里面的 scroll 模块简单快速地实现
其他
角色扮演
可以在 message 中加入一些预设对话内容,让 GPT 进行角色扮演, 例如:
我们让 GPT 扮演一个不会中文的人,结果如下:
(好家伙,不会说中文但是能看懂中文是吧 -.-!)
超长对话
由于 API 的接口有请求的字数上限 (4096 个 Token)
想要实现超长上下文记录的对话可以用到一个技巧, 当对话记录达到一定量时让 GPT 对前面的对话记录生成总结摘要
在后续对话的上文中只提供总结摘要,那么 GPT 就会大致清楚本次对话的主题
等再达到更大一个量级时, 再对摘要生成摘要
虽然越往后会有一部分 “记忆缺失”,但对于对话的大致方向还是可以记录的