一、需求
公司想要在页面中加入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:[],// 逐字输出 STARTtimer: 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,默认是1msg.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})}