最近公司h5项目需要做一个简单的ai聊天功能,用的智普ai。实现过程是将问题发给自家后端,后端返回一个答案获取接口,前端再调用答案接口,获取流式答案,并以类似打字的方式显示到聊天窗口中。
聊天窗口代码参考的文章1,根据自己的需求稍稍做了调整;流式数据获取参考的文章2,主要是用到了fecthEventSource。感谢两位大神!
AI接口文档地址:Docs
参考文章:
1、前端对接AI模型接口 实现ChatGPT流式数据并【实现打字效果】和【终止会话】_event:conversation.message.delta-CSDN博客
2、基于uniapp开发的智能客服(接入api,附完整代码)_uni-app 智能客服-CSDN博客
上代码:
<template>
<div class="main">
<div class="box">
<div style=" text-align: center;" class="title">
<!-- <img src="" alt class="logo" /> -->
<!-- <span style="font-size: 40rpx; text-align: center;" class="title-hn">智能客服</span> -->
</div>
<div id="content">
<!-- <div class="history_button" style="background-color: #f5f5f5;" v-if="isShow">
<button style=" text-align: center; color: #bcbcbc; background-color: #f5f5f5;" @click="getHistory">查看历史记录</button>
</div> -->
<div v-for="(item,index) in info" :key="index">
<div class="info_r info_default" v-if="item.type == 'leftinfo'">
<!-- <span class="circle circle_r">
<img src class="pic_r" src="/static/images/kefu2.png"></img>
</span> -->
<!-- <div class="time_r">{{item.time}}</div> -->
<div class="con_r con_text">
<div v-html="formatContent(item.content)"
></div>
</div>
</div>
<div class="info_l" v-if="item.type == 'rightinfo'">
<!-- <div class="time_l">{{item.time}}</div> -->
<div class="con_r con_text">
<span class="con_l">{{item.content}}</span>
</div>
</div>
</div>
</div>
<div class="setproblem">
<!-- <textarea
placeholder="请输入您的问题..." id="text"
v-model="customerText" @keyup.enter="sentMsg()"
@focus="scrollToView"
></textarea> -->
<input placeholder="请输入..." id="text"
v-model="customerText"
@focus="scrollToView"
/>
<button @click="sentMsg()" class="setproblems"
style="background-color: #CC4A2D;"
>
<span style="vertical-align: 8px;font-size: 30rpx;">
发 送
</span>
</button>
</div>
</div>
</div>
</template>
<script>
// 自家后端的接口,发送问题,返回ai答案获取接口
import {
getSign
} from '@/api/aiChat/aiChat.js';
// 安装fetch-event-source后引入,通过fetch-event-source获取流式答案数据
import {
fetchEventSource
} from '@microsoft/fetch-event-source'
export default {
name: "onlineCustomer",
components: {},
computed: {},
data() {
return {
userId: null,
customerText: "",
isShow: true,
Sending: true,
getHistoryId: "",
info: [{
type: "leftinfo",
time: this.getTodayTime(),
name: "robot",
content: "你好,这里是智能匹配",
question: [],
}, ],
timer: null,
keyboardHeight: 0,
};
},
created() {
this.showTimer();
},
onLoad() {
this.GetRequest();
},
mounted() {
window.addEventListener('resize', this.handleResize);
},
beforeDestroy() {
uni.offKeyboardHeightChange();
window.removeEventListener('resize', this.handleResize);
},
watch: {
keyboardHeight(newHeight) {
this.onKeyboardShow(300);
if (newHeight > 0) {
// this.onKeyboardShow(230);
} else {
this.onKeyboardHide();
}
}
},
methods: {
///换行符、加粗符更换为适配html
formatContent(content) {
// 处理换行符
let formattedContent = content.replace(/\n/g, '<br>');
// 处理加粗
formattedContent = formattedContent.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>');
return formattedContent;
},
handleResize() {
this.keyboardHeight = 0; // 重置键盘高度
},
onKeyboardShow(height) {
this.$nextTick(() => {
const content = document.getElementById('content');
content.style.paddingBottom = `${height}rpx`;
content.scrollTop = content.scrollHeight;
});
},
onKeyboardHide() {
this.$nextTick(() => {
const content = document.getElementById('content');
content.style.paddingBottom = '0px';
});
},
scrollToView() {
this.$nextTick(() => {
document.getElementById('text').scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
},
//获取历史数据
getHistory() {
var getHistory = uni.getStorageSync(this.getHistoryId);
if (!getHistory) {
uni.showToast({
title: '暂无历史记录',
icon: 'none',
duration: 2000
});
return;
}
console.log('获取历史数据');
this.info = [];
console.log(getHistory);
var i = 0;
for (i; i < getHistory.length; i++) {
this.info.push(getHistory[i]);
}
// console.log(getHistory);
this.isShow = false;
},
// 用户发送消息
sentMsg() {
clearTimeout(this.timer);
this.showTimer();
let text = this.customerText.trim();
if (!this.Sending) {
uni.showToast({
title: '稍等片刻~',
icon: 'none',
duration: 2000
});
return;
}
if (text != "") {
var obj = {
type: "rightinfo",
time: this.getTodayTime(),
content: text,
};
this.info.push(obj);
this.saveChatRecord(obj);
this.Sending = false; // 不能两个消息同时发
this.appendRobotMsg(this.customerText);
this.customerText = "";
this.$nextTick(() => {
var contentHeight = document.getElementById("content");
contentHeight.scrollTop = contentHeight.scrollHeight;
});
}
},
// 机器人回答消息
async appendRobotMsg(text) {
clearTimeout(this.timer);
this.showTimer();
text = text.trim();
// 获取ai答案接口
var data = {
question: text
}
const answerUrl = await getSign(data)
let obj2 = {
type: "leftinfo",
time: this.getTodayTime(),
name: "robot",
content: '正在输入中...',
question: [],
};
this.info.push(obj2);
this.$nextTick(() => { //滚动聊天到底部
var contentHeight = document.getElementById("content");
contentHeight.scrollTop = contentHeight.scrollHeight;
});
let abortController = new AbortController()
// 存获取到的流式数据中要显示的内容
let answerArr = []
const that = this
fetchEventSource(answerUrl, {
method: 'post',
headers: {
Accept: 'text/event-stream',
Authorization: 'Bearer <你的apikey>' // 智谱AI的key,后端提供
},
signal: abortController.signal,
async onopen(response) {
//建立连接的回调
console.log('建立连接的回调')
},
onmessage(msg) {
// console.log("流式数据", msg);
//接收一次数据段时回调,因为是流式返回,所以这个回调会被调用多次
if (msg.event == 'add' && JSON.parse(msg.data).msg) {
//进行连接正常的操作
try {
const dataObj = JSON.parse(msg.data).msg
// 存获取到的流式数据中要显示的内容
answerArr.push(dataObj)
} catch (e) {}
}
},
onclose() {
// 流式数据获取结束后的操作
// that.info[that.info.length - 1].content = answerArr.join('')
// 将获取到的流式数据要显示的内容以类似打字的方式展示到聊天窗口中
let index = 0;
let combinedString = '';
const intervalId = setInterval(() => {
// 检查是否还有未处理的数组项
if (index < answerArr.length) {
// 将当前项添加到组合字符串中
combinedString += answerArr[index];
// 更新字符串a
that.info[that.info.length - 1].content = combinedString;
that.$nextTick(() => { //滚动聊天到底部
var contentHeight = document.getElementById("content");
contentHeight.scrollTop = contentHeight.scrollHeight;
});
index++;
} else {
// 答案都显示后,才能再次提问
that.Sending = true;
// 如果所有项都已处理完毕,清除定时器
clearInterval(intervalId);
}
}, 200);
that.$nextTick(() => { //滚动聊天到底部
var contentHeight = document.getElementById("content");
contentHeight.scrollTop = contentHeight.scrollHeight;
});
},
onerror(err) {
//连接出现异常回调
// 取消请求
throw err
}
})
},
saveChatRecord(newChatRecord) {
// 获取当前存储的聊天记录列表,如果不存在则初始化为空数组
let chatRecords = uni.getStorageSync(this.getHistoryId);
// 如果不存在,则初始化为空数组
if (!Array.isArray(chatRecords)) {
chatRecords = [];
}
// 将新的聊天记录对象追加到聊天记录列表中
chatRecords.push(newChatRecord);
// 将更新后的聊天记录列表保存回本地存储
uni.setStorageSync(this.getHistoryId, chatRecords);
},
// 结束语
endMsg() {
let happyEnding = {
type: "leftinfo",
time: this.getTodayTime(),
content: "感谢您使用智能匹配,祝您生活愉快",
question: [],
};
this.info.push(happyEnding);
this.$nextTick(() => {
var contentHeight = document.getElementById("content");
contentHeight.scrollTop = contentHeight.scrollHeight;
});
},
showTimer() {
// this.timer = setTimeout(this.endMsg, 1000 * 180);
},
getTodayTime() {
// 获取当前时间
var day = new Date();
let seconds = day.getSeconds();
if (seconds < 10) {
seconds = "0" + seconds;
} else {
seconds = seconds;
}
let minutes = day.getMinutes();
if (minutes < 10) {
minutes = "0" + minutes;
} else {
minutes = minutes;
}
let time =
day.getFullYear() +
"-" +
(day.getMonth() + 1) +
"-" +
day.getDate() +
" " +
day.getHours() +
":" +
minutes +
":" +
seconds;
return time;
},
GetRequest() {
var url = location.search; //获取url中"?"符后的字串
console.log(url);
var theRequest = new Object();
if (url.indexOf("?") != -1) {
var str = url.substr(1); // 去掉"?"前缀
var params = str.split("&"); // 按照"&"分割成数组
for (var i = 0; i < params.length; i++) {
var keyValue = params[i].split("="); // 按照"="分割成键和值
var key = keyValue[0]; // 键
var value = keyValue[1]; // 值
theRequest[key] = decodeURIComponent(value); // 将键和值存入对象
}
}
// console.log(theRequest.userId);
// console.log(typeof this.userId); //object
// this.userId = String(theRequest.userId);
// this.getHistoryId = 'getHistory_' + this.userId;
// console.log(typeof this.userId); //String
// return theRequest["userId"];
}
},
props: {},
destroyed() {},
};
</script>
<style lang="scss" scoped>
// html, body {
// height: 100%;
// margin: 0;
// padding: 0;
// }
body {
-webkit-user-select: text;
/* 兼容Webkit内核浏览器 */
-moz-user-select: text;
/* 兼容Firefox */
-ms-user-select: text;
/* 兼容IE */
user-select: auto;
/* 允许文本选择 */
}
.main {
width: 100%;
height: 90%;
// background: linear-gradient(
// 180deg,
// rgba(149, 179, 212, 1) 0%,
// rgba(74, 131, 194, 1) 100%
// );
background: #f5f5f5;
overflow: hidden;
// .contentoy {
// width: 100%;
// height: 20%;
// // background-size: 100% auto;
// // padding: 0;
// }
.box {
width: 100%;
max-width: 800rpx;
/* width: 680px; */
height: 97%;
background-color: #fafafa;
position: fixed;
padding-left: 20rpx;
padding-right: 20rpx;
// padding: 1.25rem;
#content {
// height: calc(100% - 50rpx);
height: 88%;
overflow-y: scroll;
font-size: 16px;
width: 95%;
-webkit-user-select: auto;
// box-sizing: border-box; /* 确保 padding 和 border 在内容区内 */
.circle {
display: inline-block;
width: 40px;
height: 40px;
border-radius: 50%;
}
.con_text {
color: #333;
margin-bottom: 5px;
margin-left: 5px;
word-break: break-all;
/* 自动换行 */
overflow-wrap: break-word;
/* 避免长单词超出容器 */
user-select: auto;
/* 确保对话框文本可选择 */
}
.con_que {
color: #1c88ff;
height: 30px;
line-height: 30px;
cursor: pointer;
}
.info_r {
position: relative;
margin-top: 10px;
.circle_r {
position: absolute;
left: 0%;
}
.pic_r {
border-radius: 2px;
width: 40px;
height: 40px;
margin: 2px;
}
.con_r {
display: inline-block;
/* max-width: 253px; */
max-width: 70%;
/* 限制对话框最大宽度 */
min-width: 30px;
/* 限制对话框最小宽度 */
min-height: 40px;
/* min-height: 20px; */
background-color: #e2e2e2;
border-radius: 6px;
padding: 10px;
// margin-left: 50px;
}
.time_r {
margin-left: 10px;
color: #999999;
font-size: 12px;
}
}
.info_l {
text-align: right;
color: #ffffff;
color: #3163C5;
margin-top: 10px;
.pic_l {
width: 13px;
height: 17px;
margin: 8px 10px;
}
.time_l {
margin-right: 5px;
color: #999999;
font-size: 12px;
margin-top: 5px;
}
.con_l {
display: inline-block;
max-width: 70%;
/* 限制对话框最大宽度 */
min-width: 30px;
/* 限制对话框最小宽度 */
background-color: #CC4A2D;
border-radius: 6px;
padding: 10px;
text-align: left;
color: #fff;
margin-right: 5px;
word-break: break-word;
/* 确保长单词换行 */
}
}
#question {
cursor: pointer;
}
}
}
}
.setproblem {
width: 100%;
height: 145rpx;
// height: 10%;
background-color: #ffffff;
position: fixed;
bottom: 10rpx;
left: 0;
padding: 20rpx;
box-sizing: border-box;
}
.setproblem input {
color: #000000;
width: calc(100% - 7rem);
/* 让textarea占据除按钮外的剩余空间 */
height: 100%;
padding: 10px;
box-sizing: border-box;
border: 1px solid #ccc;
/* 添加边框 */
border-radius: 5px;
/* 添加圆角 */
resize: none;
/* 禁止调整大小 */
-webkit-user-select: auto;
/* 允许选择文本 */
}
.setproblem textarea {
color: #000000;
width: calc(100% - 7rem);
/* 让textarea占据除按钮外的剩余空间 */
height: 100%;
padding: 10px;
box-sizing: border-box;
border: 1px solid #ccc;
/* 添加边框 */
border-radius: 5px;
/* 添加圆角 */
resize: none;
/* 禁止调整大小 */
-webkit-user-select: auto;
/* 允许选择文本 */
}
.setproblem button {
width: 5.875rem;
height: 2.5rem;
line-height: 2.5rem;
background: #1FC59F;
opacity: 1;
border-radius: 4px;
font-size: 10px;
color: #ffffff;
position: absolute;
right: 30rpx;
bottom: 50%;
/* 让按钮垂直居中 */
transform: translateY(50%);
/* 垂直居中的兼容性写法 */
cursor: pointer;
border: none;
}
.czkj-item-title {
line-height: 25px;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
margin-bottom: 5px;
}
.czkj-item-question {
cursor: pointer;
display: block;
padding: 8px;
position: relative;
border-bottom: 1px dashed #ccc;
line-height: 20px;
min-height: 20px;
overflow: hidden;
}
.czkj-question-msg {
float: left;
font-size: 14px;
color: #3163C5;
}
</style>