代码目录:

企业微信截图_17407272465989.png
代码
文件 index.vue
<template>
<div class="page">
<section class="page_main" ref="mainChat">
<ul class="msg_list">
<msgItem
v-for="(info, i) in chatList"
:key="i"
:role="info.role"
:avatar="info.avatar"
:content="info.content"
:direction="info.direction">
</msgItem>
<msgItem
v-if="streaming"
:streaming="true"
:role="defaultInfo.role"
:avatar="defaultInfo.avatar"
:content="streamingText"
:direction="'left'">
</msgItem>
</ul>
</section>
<section class="page_footer">
<div class="msg_type"
:class="{ audio: isDudio }"></div>
<div class="msg_cont"
:class="{ audio: isDudio }">
<input class="msg_inp"
v-show="!isDudio"
v-model="text"
type="text"
:disabled="disabled"
placeholder="请输入信息"
@keydown.enter="sendMsg">
<p class="msg_voice"
v-show="isDudio">按住说话</p>
</div>
<div class="msg_send" @click="sendMsg()"></div>
</section>
</div>
</template>
<script>
import msgItem from './components/msgItem.vue'
import { StreamGpt, Typewriter } from './scripts'
const Avatar = require('../../assets/img/ai_avatar.png')
export default {
components: {
msgItem
},
data () {
return {
// 默认信息
defaultInfo: {
role: 'Ma机器人',
avatar: Avatar
},
// 应用相关信息
appId: 'YOUR_APPID',
apikey: 'YOUR_API_KEY',
// 当前用户信息用于展示
clientInfo: {
id: 1234, // 用户id,唯一的,用于chatId的参数值
wechat_name: '八妹',
avatar: 'https://wework.qpic.cn/bizmail/2yFdlfjdoBiayEEmBImMTPsd029CEibuDhlH1mTL9ibDINuWNXJDeQbWg/0'
},
// 消息相关
isDudio: false, // 消息是否是语音
text: '', // 消息
chatList: [],
// 输入框是否禁用
disabled: false,
// 初始化数据
typewriter: null,
gpt: null,
streamingText: '',
streaming: false,
history: false
}
},
methods: {
sendMsg () {
if (this.disabled) {
return
}
if (this.text === '' || /^\s+$/.test(this.text)) {
this.$toast('请输入信息')
return
}
this.disabled = true
this.gpt.stream(
this.text,
this.clientInfo.id,
this.history ? this.chatList : undefined
)
},
scrollToBottom () {
this.$nextTick(() => {
setTimeout(() => {
const mainChat = this.$refs.mainChat
mainChat.scrollTop = mainChat.scrollHeight
}, 0)
})
},
init () {
this.typewriter = new Typewriter((str) => {
this.streamingText += str || ''
// console.log('str', str)
})
this.gpt = new StreamGpt(this.apikey, {
onStart: (prompt) => {
// console.log('onStart', prompt)
this.streaming = true
/* eslint-disable */
this.chatList.push({
role: this.clientInfo?.wechat_name || '',
avatar: this.clientInfo?.avatar || '',
content: prompt,
direction: 'right'
})
this.text = ''
this.scrollToBottom()
},
onPatch: (text) => {
// console.log('onPatch', text)
this.typewriter.add(text)
},
onCreated: () => {
// console.log('onCreated')
this.typewriter.start()
},
onDone: () => {
// console.log('onDone')
this.typewriter.done()
this.streaming = false
this.chatList.push({
role: this.defaultInfo.role,
avatar: this.defaultInfo.avatar,
content: this.streamingText,
direction: 'left'
})
this.streamingText = ''
this.disabled = false
}
})
}
},
watch: {
streamingText () {
this.scrollToBottom()
}
},
created () {
this.init()
},
beforeDestroy () {
if (this.gpt) {
this.gpt = null
}
if (this.typewriter) {
this.typewriter = null
}
}
}
</script>
<style lang="stylus" scoped>
.page
width 100%
height 100vh
background: #f6f6f6
.page_main
width 100%
height calc(100% - 50px)
overflow-y: auto
overflow-x: hidden
.msg_list
width 100%
padded_box(border-box, 15px)
.page_footer
width 100%
height 50px
background: #fff
display: flex
justify-content: center
align-items: center
padded_box(border-box, 6px 20px)
box-shadow: 0 -1px 10px 0 rgba(51,80,138,0.06);
.msg_type
width 38px
height 38px
background: url('~assets/img/chat_audio.png') no-repeat center/30px
&.audio
background-image: url('~assets/img/chat_text.png')
background-size 28px
.msg_cont
flex 1
height: 100%
&.audio
background: #f0f1f5
border-radius: 8px
margin: 0 4px
.msg_inp
width 100%
height 100%
padded_box(border-box, 9px 8px)
.msg_voice
width 100%
height 100%
line-height: 38px
text-align: center
.msg_send
width 38px
height 38px
background: url('~assets/img/chat_send.png') no-repeat center/30px
</style>
文件components/msgItem.vue
<template>
<li class="msg_item"
:class="direction">
<div class="msg_item_box">
<div class="avatar">
<img :src="avatar" alt="">
</div>
<div class="msg_wrap">
<p class="name">{{role}}</p>
<div class="msg_cont">
<div class="content"
ref="contRef"
v-html="mkHtml">
</div>
</div>
</div>
</div>
</li>
</template>
<script>
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const md = new MarkdownIt({
highlight: function (str, lang) {
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>`
}
})
export default {
props: {
role: String,
avatar: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
streaming: {
type: Boolean,
default: false
},
direction: {
type: String,
default: ''
}
},
data () {
return {}
},
computed: {
mkHtml: function () {
// 发送内容
if (this.direction === 'right') {
return this.content
}
// 回复内容
// console.log('this.content', this.content)
const htmlContent = md.render(this.content)
this.$nextTick(() => {
if (this.streaming) {
const parent = this.$refs.contRef
if (!parent) return
let lastChild = parent.lastElementChild || parent
if (lastChild.tagName === 'PRE') {
lastChild = lastChild.getElementsByClassName('hljs')[0] || lastChild
}
if (lastChild.tagName === 'OL') {
lastChild = this.findLastElement(lastChild)
}
lastChild.insertAdjacentHTML('beforeend', `<span class="input-cursor"></span>`)
// console.log('追加光标')
}
})
return htmlContent
}
},
methods: {
findLastElement (element) {
if (!element.children || !element.children.length) {
return element
}
const lastChild = element.children[element.children.length - 1]
if (lastChild.nodeType === Node.ELEMENT_NODE) {
return this.findLastElement(lastChild)
}
return element
}
}
}
</script>
<style lang="stylus" scoped>
.msg_item
width 100%
margin-bottom 20px
&.right
.msg_item_box
flex-direction: row-reverse
.msg_wrap
display: flex
flex-direction: column
align-items flex-end
.msg_cont
.content
background: #5A77FF
color #fff
border-radius: 8px 0 8px 8px
.msg_item_box
display: flex
align-items flex-start
.avatar
width 40px
height 40px
border-radius: 50%
overflow: hidden
img
width 100%
.msg_wrap
flex: 1
margin 0 10px
.name
line-height: 1.2;
color: #848484;
font-size: 13px;
margin-bottom: 5px;
.msg_cont
max-width 88%
.content
width 100%
background: #fff
border-radius: 0 8px 8px
padded_box(border-box, 12px 16px)
line-height 1.4
font-size 15px
color #333333
>>>.input-cursor
display: inline-block;
height: 16px;
margin-bottom: -3px;
width: 2px;
animation: blink 1s infinite steps(1, start)
/*这里设置动画blink*/
@keyframes blink {
0%,
100% {
background-color: #000;
color: #aaa;
}
50% {
background-color: #bbb;
/* not #aaa because it's seem there is Google Chrome bug */
color: #000;
}
}
</style>
文件scripts/index.js
/**
* 打字机
*/
export class Typewriter {
constructor (onConsume) {
this.queue = []
this.consuming = false
this.timmer = null
this.onConsume = onConsume
}
// 消费间隔
dynamicSpeed () {
const speed = 2000 / this.queue.length
if (speed > 200) {
return 200
} else {
return speed
}
}
// 添加队列
add (str) {
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)
this.onConsume(this.queue.join(''))
this.queue = []
}
}
/**
* 解析数据
*/
const parsePack = (str) => {
const pattern = /data:\s*({.*?})\s*\n/g
const result = []
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)
}
}
return result
}
// 获取流式数据
export class StreamGpt {
constructor (key, options) {
const { onStart, onCreated, onDone, onPatch } = options
this.key = key
this.onStart = onStart
this.onCreated = onCreated
this.onDone = onDone
this.onPatch = onPatch
}
async stream (prompt, chatId = null, history = [], preset = []) {
let finish = false
let count = 0
const _history = [...history]
this.onStart(prompt)
const res = await this.fetch(chatId, [
...preset,
..._history,
{
role: 'user', // role的值必须是'user'
content: prompt
}
])
if (!res.body) {
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (!finish) {
const { done, value } = await reader.read()
if (done) {
finish = true
this.onDone()
break
}
count++
const jsonArray = parsePack(decoder.decode(value))
if (count === 1) {
this.onCreated()
}
jsonArray.forEach((json) => {
if (!json.choices || json.choices.length === 0) {
return
}
const text = json.choices[0].delta.content
this.onPatch(text)
})
}
}
async fetch (chatId, messages) {
/* eslint-disable */
let url = `https://api.openai.com/v1/chat/completions`
return await fetch(url, {
method: 'POST',
body: JSON.stringify({
messages,
chatId,
stream: true,
detail: true
}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.key}`
}
})
}
}
图标

ai_avatar.png

chat_audio.png

chat_send.png

chat_text.png
踩坑
需要使用插件markdown-it和11.11.1实现markdown文本渲染,(插曲:vue版本是V4.4.6 使用v14.1.0版本的markdown-it报错,后面降低markdown-it版本为v13.0.2解决)
参考文献:
- https://blog.csdn.net/m0_74823388/article/details/144402012
- https://juejin.cn/post/7237426124669157433
最终效果:

image.png