本文介绍运用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,实际的话要判断是否超出长度而抛弃一些早前的记载
// 整合封装 vue3 composition api
import { ref } from 'vue'
import { StreamGpt, Typewriter, GptMsgs } from '../scripts'
export const useGpt = (key: string, history: boolean = false) => {
const streamingText = ref('')
const streaming = ref(false)
const msgList = ref<GptMsgs>([])
const typewriter = new Typewriter((str: string) => {
streamingText.value += str || ''
console.log('str', str)
})
const gpt = new StreamGpt(key, {
onStart: (prompt: string) => {
streaming.value = true
msgList.value.push({
role: 'user',
content: prompt
})
},
onPatch: (text: string) => {
console.log('onPatch', text)
typewriter.add(text)
},
onCreated: () => {
typewriter.start()
},
onDone: () => {
typewriter.done()
streaming.value = false
msgList.value.push({
role: 'system',
content: streamingText.value
})
streamingText.value = ''
}
})
// 假如是history形式,则在strame时将msgList传入
const stream = (prompt: string) => {
gpt.stream(prompt, history ? msgList.value : undefined)
}
return {
streamingText,
streaming,
msgList,
stream
}
}
运用
const { msgList, streaming, streamingText, stream } = useGpt('你的key', true)
// 发送内容
const handleSubmit = (content: string) => {
if (content === '') return
stream(content)
}
渲染Markdown
现在咱们有了解析好的内容, 能够看到是Markdown的格局,咱们要将它转换成HTML,试了几个库后选择了Markdown-it
- 装置 markdown-it
npm install markdown-it
- 引进并初始化
import MarkdownIt from 'markdown-it'
const md: MarkdownIt = MarkdownIt()
- 解析md内容
let html = md.render(props.content)
代码块高亮
假如要让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改写
const md: MarkdownIt = MarkdownIt({ highlight: function (str: string, lang: string) { if (lang && hljs.getLanguage(lang)) { try { return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value }</code></div></div>` } catch (__) { console.log(__, 'error') } } return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${md.utils.escapeHtml( str )}</code></div></div>` } })
同时在钩子函数给代码块刺进一个class为hl-code-header的头部,来完成代码语言的显现
光标闪耀作用
在打字机输出文字时,给尾部加一个闪耀的光标
- 在解分出的HTML结构中,找到最终一个元素并在这个元素的尾部刺进一个光标元素
- 给它加上闪耀动画
- 对一些特别的标签特别处理,例如代码块的PRE标签
- 对于深层嵌套的元素递归查找到最终一个元素
// 获取展现内容的容器
const parent = popRef.value
if (!parent) return
// 获取最终一个子元素节点
let lastChild = parent.lastElementChild || parent
// 假如是pre标签,就在pre标签中找到class为hljs的元素
if (lastChild.tagName === 'PRE') {
lastChild = lastChild.getElementsByClassName('hljs')[0] || lastChild
}
// 兼容是ul标签的情况,找到OL标签内部的最终一个元素
if (lastChild.tagName === 'OL') {
lastChild = findLastElement(lastChild as HTMLElement)
}
// 向最终一个子元素中刺进span标签完成光标
lastChild?.insertAdjacentHTML('beforeend', '<span class="input-cursor"></span>')
// 递归找到DOM下最终一个元素节点
function findLastElement(element: HTMLElement): HTMLElement {
// 假如该DOM没有子元素,则回来本身
if (!element.children.length) {
return element
}
const lastChild = element.children[element.children.length - 1]
// 假如最终一个子元素是元素节点,则递归查找
if (lastChild.nodeType === Node.ELEMENT_NODE) {
return findLastElement(lastChild as HTMLElement)
}
return element
}
实时滚到到底部
经过vue的watch监听streamingText来判断是否新增了内容,让页面随着内容的输出翻滚到底部,让阅览的体会更上一层楼
给咱们推荐一个VUE3的hooks库 vueuse,功用非常完全,这儿翻滚的逻辑就用里面的scroll模块简略快速地完成
import { useScroll } from '@vueuse/core'
// ...
// 翻滚的元素
const listEl = ref()
const { y } = useScroll(listEl)
const scrollToBottom = () => {
nextTick(() => {
y.value = listEl.value?.scrollHeight || 0
})
}
// 监听streamingText变化,翻滚到底部
watch(streamingText, (val) => {
if (val) {
scrollToBottom()
}
})
其他
角色扮演
能够在message中参加一些预设对话内容,让GPT进行角色扮演,例如:
const preset = [{ 'role': 'user', content: '请扮演一个不会说中文的人和我对话' }, { 'role': 'system', content: 'sorry, I do not speak Chinese' }]
// 建议恳求时在messages中参加参加预设的内容
const res = await this.fetch([...preset, ..._history, { 'role': 'user', content: prompt }])
咱们让GPT扮演一个不会中文的人,结果如下:
(好家伙,不会说中文但是能看懂中文是吧 -.-!)
超长对话
由于API的接口有恳求的字数上限(4096个Token)
想要完成超长上下文记载的对话能够用到一个技巧, 当对话记载到达一定量时让GPT对前面的对话记载生成总结摘要
在后续对话的上文中只供给总结摘要,那么GPT就会大致清楚本次对话的主题
等再到达更大一个量级时,再对摘要生成摘要
虽然越往后会有一部分“记忆缺失”,但对于对话的大致方向仍是能够记载的