一、需求
公司想要在页面中加入AI智能对话功能,故查找免费gpt接口,最终决定百度千帆大模型(进入官网、官方文档中心);
二、主要功能列举
- AI智能对话;
- 记录上下文回答环境;
- 折叠/展开窗口;
- 可提前中止回答;
- 回答内容逐字展示并语音播报;
三、效果图
四、技术选型
1、前端环境
- node(14.21.3)
- VueCli 2
- element-ui(^2.15.14)
- axios
- node-sass(^4.14.1)
- sass-loader(^7.3.1)
- js-md5(^0.8.3)
2、后端环境
- JDK8
- springboot
五、声明
- 本文章以及源码纯粹自己写着玩,等于是个demo,有许多需要完善和优化的地方,仅供大家参考,有错误的地方欢迎大家批评指正~~
- 本菜其实是java,前端代码中如果看到神奇的地方,多多包涵,哈哈哈哈
四、百度千帆大模型应用创建
1、访问官网,注册账号并登录;
点击打开官网
2、选择“应用接入”-“创建应用”
进去填一个应用名及描述即可,服务默认全勾选上;
3、保存后返回应用列表,获取api key和secret key
PS:
百度提供的大模型服务有好多种,我此处是白嫖的其中一个免费的,如下图:
其中ERNIE开头的是百度自己的,文心一言用的就是这种,其他有些是三方大模型;
具体不同服务之间有什么区别可以看官方介绍,个人觉得免费的几个主要在于轻量等级、响应速度、回答内容复杂程度、可保存的上下文大小等几个方面;
五、部分后台代码
1、官网下载java sdk或者引入百度千帆pom
官放文档地址:https://cloud.baidu.com/doc/WENXINWORKSHOP/s/7ltgucw50
java SDK地址:https://github.com/baidubce/bce-qianfan-sdk/tree/main/java
maven仓库地址:https://mvnrepository.com/artifact/com.baidubce/qianfan
POM:
<!-- https://mvnrepository.com/artifact/com.baidubce/qianfan -->
<dependency>
<groupId>com.baidubce</groupId>
<artifactId>qianfan</artifactId>
<version>0.0.4</version>
</dependency>
2、创建springboot项目,并导入sdk或引入千帆pom
此处是把sdk导入到工程中;
3、项目代码结构如下
pom.xml :
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.baidu</groupId>
<artifactId>aichat</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<name>aichat</name>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.9</version>
</parent>
<dependencies>
<!-- SpringBoot的依赖配置 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.2.13.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.1.1.RELEASE</version>
<configuration>
<fork>true</fork> <!-- 如果没有该配置,devtools不会生效 -->
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
<warName>${project.artifactId}</warName>
</configuration>
</plugin>
</plugins>
<finalName>${project.artifactId}</finalName>
</build>
</project>
AiChatController.java:
package com.baidubce.controller;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.baidubce.qianfan.Qianfan;
import com.baidubce.qianfan.core.builder.ChatBuilder;
import com.baidubce.qianfan.model.chat.ChatResponse;
import com.baidubce.qianfan.model.chat.Message;
import com.baidubce.utils.JsonUtils;
import com.baidubce.utils.SecUtils;
@RestController
public class AiChatController {
private static final String accessKey = "你创建的应用的API Key";
private static final String secretKey = "你创建的应用的Secret Key";
private static Qianfan qianfan = new Qianfan("OAuth", accessKey, secretKey);
/**
* 参数:
* messages: 对话记录,role:user是用户,assistant是AI,如:[{"role":"user","content":"1"},{"role":"assistant","content":"“1”是一个数字。"},{"role":"user","content":"你是"},{"role":"assistant","content":""}]
* timestamp:请求毫秒值,1717742825695
* signature: 签名,4bc9c3b8dbe4de5bc924b6fa0506c606
* @author x轩
* @version 2024年6月7日 下午2:46:32
*/
@PostMapping("/sendMsg")
public String sendMsg(@RequestBody Map<String, Object> params) {
// 验签,我自己加的,防止恶意调用,作用不大,提高门槛而已
if(!checkSign(params)) {
return "签名不正确!";
}
String result = null;
try {
result = chat(String.valueOf(params.get("messages")));
} catch (Exception e) {
e.printStackTrace();
return "接口繁忙,请稍后再试!";
}
return result;
}
/**
* 参数:
* messages: 业务参数
* timestamp:请求毫秒值
* signature: 签名
*
* 加签规则:
* 要求1:timestamp和当前系统时间不能超过5秒钟
* 要求2:MYCHAT|timestamp|messages拼接后MD53次加密
*
* @author x轩
* @version 2024年6月6日 下午4:13:03
*/
private boolean checkSign(Map<String, Object> params) {
String timestamp = String.valueOf(params.get("timestamp"));
String messages = String.valueOf(params.get("messages"));
String signature = String.valueOf(params.get("signature"));
if(StringUtils.isAnyBlank(timestamp, messages, signature)) {
return false;
}
// 1.判断时间
if((System.currentTimeMillis()- Long.valueOf(timestamp))>5000) {
// 过期
return false;
}
// 2.验签
String p = "MYCHAT|"+timestamp+"|"+messages;
String md5of3 = SecUtils.encoderByMd5With32Bit(SecUtils.encoderByMd5With32Bit(SecUtils.encoderByMd5With32Bit(p)));
if(!signature.equalsIgnoreCase(md5of3)) {
return false;
}
return true;
}
public static void main(String[] args) {
// chat("对于调休你怎么看");
chatStream("介绍一下自己");
}
private static String chat(String messages) {
ChatBuilder bulder = qianfan.chatCompletion()
// .model("ERNIE-Speed-128K")
// .model("ERNIE-Speed-8K")
.model("ERNIE-Tiny-8K");
List<Message> messageList = JsonUtils.readValues(messages, Message.class);
// 过滤一下,去除空内容对象
messageList = messageList.stream().filter(m->{
return StringUtils.isNotBlank(m.getContent());
}).collect(Collectors.toList());
for(Message m : messageList) {
bulder.addMessage(m);
}
ChatResponse response = bulder.execute();
return response.getResult();
}
private static void chatStream(String message) {
Iterator<ChatResponse> stream = qianfan.chatCompletion()
// .model("ERNIE-Speed-128K")
// .model("ERNIE-Speed-8K")
.model("ERNIE-Tiny-8K").addMessage("user", message).executeStream();
while(stream.hasNext()) {
System.out.println(stream.next().getResult());
}
}
}
六、 Vue部分代码
1、vue.config.js
const port = process.env.port || process.env.npm_config_port || 80 // 端口
module.exports = {
lintOnSave: false,
publicPath: "/aichat-front",
assetsDir: 'static',
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
devServer: {
host: '0.0.0.0',
port: port,
open: true,
proxy: {
['/aichat']: {
target: `http://127.0.0.1:8080/aichat`,
changeOrigin: true,
pathRewrite: {
['^/aichat']: ''
}
},
},
},
}
2、App.vue
<template>
<div id="app">
<qian-fan-chat/>
</div>
</template>
<script>
import QianFanChat from './components/QianFanChat'
export default {
name: 'App',
components: {
QianFanChat
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
3、@/components/QianFanChat组件
<template>
<div class="chat-div">
<div v-show="showChatBox" class="chat-main">
<div id="messagediv" class="messagediv">
<div class="item">
<img class="avatar" :src="aiAvatar" >
<div class="answerDiv">
<p>我是AI智能小助手,有问题请咨询我吧!</p>
</div>
</div>
<div v-for="(item, index) in messageList" class="item">
<img v-if="item.role=='assistant'" class="avatar" :src="aiAvatar" >
<img v-if="item.role=='user'" class="avatar" :src="userAvatar" >
<div class="answerDiv">
<p v-if="!item.loading" v-html="item.content"></p>
<p v-else>思考中 <i class="el-icon-loading"></i></p>
<a v-if="index==messageList.length-1 &&item.role=='assistant' && (loading || speaking)" href="#" @click="stopAnswer">停止回答</a>
</div>
</div>
</div>
<div class="sendDiv">
<el-input @keyup.enter.native="getAnswer" v-model="question" placeholder="输入中医药相关内容搜一搜"></el-input>
<el-button @click="getAnswer">
<i class="el-icon-s-promotion"></i>
</el-button>
</div>
</div>
<div v-show="showChatBox" class="close-btn" @click="showChatBox=false">
<i class="el-icon-close"></i>
</div>
<div v-show="!showChatBox" class="small-window" @click="showChatBox=true">
AI问答
</div>
</div>
</template>
<script>
const msg = new SpeechSynthesisUtterance();
import { sendMsg } from '@/api/aichat';
import { sign } from '@/utils/securityUtil'
import aiAvatar from '@/assets/images/ai-avatar.jpg';
import userAvatar from '@/assets/images/user-avatar.jpg';
export default {
name: 'QianFanChat',
data(){
return {
showChatBox: false,
loading: false,
speaking: false,
aiAvatar,userAvatar,
question:'',
messageList:[
],
// 逐字输出 START
timer: null,
length: 0,
index: 0,
// 逐字输出 END
}
},
mounted(){
},
methods: {
getAnswer(){
if(this.loading){
this.$message({type: 'warning', message:'正在回答,请耐心等待!'});
return;
}
if(!this.question.trim()){
this.$message({type: 'warning', message:'请输入内容!'});
return;
}
this.loading = true;
let tmpQustion = this.question;
this.question = '';
this.messageList.push({ role:'user', content: tmpQustion, loading: false });
this.messageList.push({ role:'assistant', content: '' , loading: true});
this.$nextTick(()=>{
this.scroll();
})
let params = {
messages: JSON.stringify(this.messageList)
};
params.timestamp = new Date().getTime();
params.signature = sign(params);
sendMsg(params).then(res=>{
// 判断loading是否被中断(停止回答可中断)
if(!this.loading){
// 点击了“停止回答”
return;
}
// 接口请求完毕,替换最后一条内容
this.messageList[this.messageList.length-1].loading = false;
this.index = 0;
this.length = res.length;
this.handleSpeak(res);
// 一个字一个字给我蹦
this.timer = setInterval(()=>{
if(this.index<=this.length-1){
let word = res.charAt(this.index);
if(word=='\n'){
word = '<br>'
}
this.messageList[this.messageList.length-1].content += word;
this.index++;
}else{
// 结束
this.loading = false;
clearInterval(this.timer);
}
this.scroll();
}, 30);
})
},
scroll(){
messagediv.scrollTo({
top: messagediv.scrollHeight,
})
},
stopAnswer(){
this.handleStop();
clearInterval(this.timer);
this.length = 0;
this.index = 0;
this.loading = false;
// 判断最后一条内容是不是空,是则给上默认输出
let lastMsg = this.messageList[this.messageList.length-1];
if(!lastMsg.content){
lastMsg.loading = false;
lastMsg.content = '请继续向我提问吧!';
}
},
// 语音播报的函数
handleSpeak(text) {
this.handleStop();
this.speaking = true;
// 处理多音字
msg.text = text; // 朗读内容
msg.lang = "zh-CN"; // 使用的语言:中文
msg.volume = 0.5; // 声音音量:1 设置将在其中发言的音量。区间范围是0到1,默认是1
msg.rate = 1.6; // 语速:1 设置说话的速度。默认值是1,范围是0.1到10,表示语速的倍数,例如2表示正常语速的两倍
msg.pitch = 1.5; // 音高:2 设置说话的音调(音高)。范围从0(最小)到2(最大)。默认值为1
// msg.voiceURI = 'Google 普通话(中国大陆)';
msg.onstart = (e)=>{
};
msg.onend = (e)=>{
this.speaking = false;
};
msg.onboundary = (e) => {
}
speechSynthesis.speak(msg); // 播放
},
// 语音停止
handleStop(e) {
this.speaking = false;
msg.text = e;
msg.lang = "zh-CN";
speechSynthesis.cancel(msg);
},
}
}
</script>
<style lang="scss" scoped>
::v-deep {
.el-input {
width: 270px;
input {
background-color: rgba(0,0,0,.5);
border: none;
color: white;
}
}
.el-button {
background-color: rgba(0,0,0,.5);
border: none;
margin-left: 10px;
padding: 0 20px;
i {
font-size: 20px;
color: white;
}
&:focus {
background-color: rgba(0,0,0,.5);
}
&:hover {
background-color: white;
i {
color: black;
}
}
}
}
.chat-div {
position: absolute;
top: 0;
left: 0;
display: flex;
z-index: 99;
cursor: pointer;
.close-btn {
margin-top: 18px;
color: white;
background-color: rgba(0, 0, 0, .6);
height: 30px;
width: 30px;
text-align: center;
line-height: 30px;
border-radius: 50%;
}
.small-window {
color: white;
margin-top: 18px;
padding: 10px;
background-color: rgba(16, 168, 129, .8);
width: 26px;
font-size: 20px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
}
.chat-main {
border-radius: 4px;
margin: 10px 0;
padding: .1rem .1rem;
width: 374px;
.messagediv {
overflow: auto;
max-height: 60vh;
.item {
display: flex;
margin-bottom: .1rem;
.avatar {
width: 40px;
height: 40px;
}
.answerDiv {
p {
color: white;
background-color: rgba(0, 0, 0, .6);
padding: 10px;
margin: 0 10px;
font-size: 16px;
border-radius: 6px;
text-align: left;
}
a {
cursor: pointer;
font-size: 15px;
color: red;
text-decoration: underline;
margin-left: 10px;
font-weight: bold;
}
}
}
&::-webkit-scrollbar {
width: 0;
}
}
.sendDiv {
display: flex;
justify-content: end;
margin: 10px 10px 0 0;
}
}
</style>
4、@/utils/request.js
import axios from 'axios'
import { Notification, MessageBox, Message } from 'element-ui'
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项,表示请求URL公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 50000
})
// request拦截器
service.interceptors.request.use(config => {
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?';
for (const propName of Object.keys(config.params)) {
const value = config.params[propName];
var part = encodeURIComponent(propName) + "=";
if (value !== null && typeof (value) !== "undefined") {
if (typeof value === 'object') {
for (const key of Object.keys(value)) {
if (value[key] !== null && typeof (value[key]) !== 'undefined') {
let params = propName + '[' + key + ']';
let subPart = encodeURIComponent(params) + '=';
url += subPart + encodeURIComponent(value[key]) + '&';
}
}
} else {
url += part + encodeURIComponent(value) + "&";
}
}
}
url = url.slice(0, -1);
config.params = {};
config.url = url;
}
return config
}, error => {
console.log(error)
Promise.reject(error)
})
// 响应拦截器
service.interceptors.response.use(res => {
return res.data;
},
error => {
console.log('err' + error)
let { message } = error;
if (message == "Network Error") {
message = "后端接口连接异常";
}
else if (message.includes("timeout")) {
message = "系统接口请求超时";
}
else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常";
}
Message({
message: message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)
export default service
5、@/utils/securityUtil.js
PS: 由于这个项目不需要登录,我又怕接口泄露导致别人恶意调用,所以给接口加了个签名,具体策略大家可以自定义(讲真的,没什么用,前台加签别人打开调试模式照样可以看到加签策略。。。为了应对这个情况,我把前台加签JS给做了个混淆,算是增加一下门槛吧;还可以禁止用户点击F12和右键事件【具体代码见此篇文章】)
// 加签方法,方法接收两个参数: timestamp和messages(消息JSON字符串),返回签名;已混淆,以下代码具体签名策略如下:固定字符串"MYCHAT"、时间戳、消息体用|拼接后进行3次MD5加密:如MYCHAT|1718181053994|[{"role":"user","content":"1"},{"role":"assistant","content":"“1”是一个数字。"}]
const _0x4e66=['MYCHAT','timestamp'];const _0x3524=function(_0x4e6602,_0x35247b){_0x4e6602=_0x4e6602-0x0;let _0x2ee47d=_0x4e66[_0x4e6602];return _0x2ee47d;};import _0x50d0de from'js-md5';export function sign(_0x5c789a){let _0x1bf721='|'+_0x5c789a[_0x3524('0x1')];let _0x202f97='|'+_0x5c789a['messages'];let _0x74f806=_0x3524('0x0')+_0x1bf721+_0x202f97;_0x74f806=_0x50d0de(_0x74f806);_0x74f806=_0x50d0de(_0x74f806);_0x74f806=_0x50d0de(_0x74f806);return _0x74f806;}
6、@/api/aichat.js
import request from '@/utils/request'
export function sendMsg(data) {
return request({
url: '/sendMsg',
method: 'post',
data: data
})
}