一、需求
公司想要在页面中加入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 }) }
复制