1.前言
本文主要介绍如何在若依框架前后端分离版实现AI对话。由于目前AI非常多,我以通义千问为例进行介绍。文章会详细介绍前后端如何进行代码设计,后端调用AI接口后,采用推流的方式返回前端,并且将对话内容保存在数据库中。并不会和现在大多数文章一样,前端介绍草草结束,而是设计了完整的对话窗口,并且完成了流数据拼接和格式转换。也就是说,哪怕不用通义千问这个AI,也可以将前端页面集成到自己的系统里面。但是需要注意点是,我只集成了单轮对话,并且通过文生文的方式进调用。对于多轮对话和上传文件等功能没有实现,但是在对话窗口中预留了文件上传功能,能够正常保存,只是没有实现此功能罢了。
2.后端配置
1.对于简单的若依框架如何启动和配置,我这里就不介绍了,如果有需要可以查看我的主页文章。需要去阿里云获取通义千问的应用id和key,具体可以去阿里云官网查看首次调用通义千问API。
2.根据下面的sql,生成表py_message。
CREATE TABLE `py_message` (
`id` varchar(32) CHARACTER SET utf8 NOT NULL COMMENT 'id',
`owner` varchar(30) CHARACTER SET utf8 DEFAULT NULL COMMENT '所属人',
`file_path` varchar(511) CHARACTER SET utf8 DEFAULT NULL COMMENT '文件路径',
`bot_flag` char(1) CHARACTER SET utf8 DEFAULT '0' COMMENT '是否为机器人消息(0代表否,2代表是)',
`text` text CHARACTER SET utf8 COMMENT '内容',
`del_flag` char(1) CHARACTER SET utf8 DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
`create_by` varchar(64) CHARACTER SET utf8 DEFAULT '' COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) CHARACTER SET utf8 DEFAULT '' COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) CHARACTER SET utf8 DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='消息信息表';
3.表单操作我使用的mybatis-plus,如果没有集成,可以查看我的文章《若依前后端分离版创建Mybatis-Plus代码生成器》。如果已经根据我的文章集成了mybatis-plus代码生成器,需要修改的地方很少。为了兼顾所有的小伙伴,我会介绍每个类。打开src/main/resources/application.yml文件,替换成自己的通义千问应用的id和key。
#ai配置
ai:
# 通义千问配置
tyQw:
id:
key:
4.根据包和类结构,将下面代码替换到对应位置的类中。
4.1src/main/java/com/ruoyi/domain/BasePojo.java代码如下:
package com.ruoyi.domain;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
@Data
public class BasePojo {
//创建者id
@TableField("create_by")
private String createBy;
//创建时间
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
//更新者id
@TableField("update_by")
private String updateBy;
//更新时间
@TableField("update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
//是否可用,2代表可用,0代表删除
@TableField("del_flag")
@TableLogic(value = "0", delval = "2")
private String delFlag;
}
4.2src/main/java/com/ruoyi/util/ConfigUtil.java代码如下:
package com.ruoyi.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* @author py
* @date 2025/3/10 16:51
* @description 由于静态变量无法获取配置中的,通过此类中转下
*/
@Component
public class ConfigUtil {
// 通义千问id
@Value("${ai.tyQw.id}")
private String tyQwId;
// 通义千问key
@Value("${ai.tyQw.key}")
private String tyQwKey;
// 类注入容器时执行
@PostConstruct
public void init() {
AiUtil.init(tyQwId, tyQwKey);
}
}
4.3src/main/java/com/ruoyi/util/AiUtil.java代码如下:
package com.ruoyi.util;
import com.alibaba.dashscope.app.Application;
import com.alibaba.dashscope.app.ApplicationParam;
import com.alibaba.dashscope.app.ApplicationResult;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import io.reactivex.Flowable;
/**
* @author py
* @date 2025/3/10 15:59
* @description ai工具类
*/
public class AiUtil {
// 通义千问id
private static String tyQwId;
// 通义千问key
private static String tyQwKey;
// 获取初始值
public static void init(String tyQwId, String tyQwKey) {
AiUtil.tyQwId = tyQwId;
AiUtil.tyQwKey = tyQwKey;
}
/**
* 获取通义千问消息
* @param prompt 提示词
* @return
* @throws NoApiKeyException
* @throws InputRequiredException
*/
public static String getTyQwMessage(String prompt) throws NoApiKeyException, InputRequiredException {
ApplicationParam param = ApplicationParam.builder()
.apiKey(tyQwKey)
.appId(tyQwId)
.prompt(prompt)
.build();
Application application = new Application();
ApplicationResult result = application.call(param);
return result.getOutput().getText();
}
public static Flowable<ApplicationResult> getTyQwFluxMessage(String prompt) throws NoApiKeyException, InputRequiredException {
ApplicationParam param = ApplicationParam.builder()
.apiKey(tyQwKey)
.appId(tyQwId)
.prompt(prompt)
.incrementalOutput(true)
.build();
Application application = new Application();
Flowable<ApplicationResult> result = application.streamCall(param);
return result;
}
}
4.4src/main/java/com/ruoyi/util/QueryWrapperUtil.java代码如下:
package com.ruoyi.util;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ruoyi.domain.BasePojo;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
public class QueryWrapperUtil {
public static <T extends BasePojo> QueryWrapper<T> getWrapper(T entity) {
QueryWrapper<T> wrapper = new QueryWrapper<>();
Class<? extends BasePojo> entityClass = entity.getClass();
Field[] declaredFields = getAllDeclaredFields(entityClass);
for (Field declaredField : declaredFields) {
String name = declaredField.getName();
if (!name.equals("serialVersionUID")) {
String getName = StrUtil.upperFirstAndAddPre(name, "get");
Method method = null;
try {
method = entityClass.getMethod(getName);
Object obj = method.invoke(entity);
String column = StrUtil.toSymbolCase(name, '_');
wrapper.eq(ObjectUtil.isNotEmpty(obj), column, obj);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
return wrapper;
}
public static <T extends BasePojo> QueryWrapper<T> getWrapper(T entity, Map<String, String> map) {
QueryWrapper<T> wrapper = new QueryWrapper<>();
Class<? extends BasePojo> entityClass = entity.getClass();
Field[] declaredFields = getAllDeclaredFields(entityClass);
for (Field declaredField : declaredFields) {
String name = declaredField.getName();
if (!name.equals("serialVersionUID")) {
String getName = StrUtil.upperFirstAndAddPre(name, "get");
Method method = null;
try {
method = entityClass.getMethod(getName);
Object obj = method.invoke(entity);
String column = StrUtil.toSymbolCase(name, '_');
String mybatisValue = map.get(name);
if (StrUtil.isNotEmpty(mybatisValue)) {
switch (mybatisValue) {
case "isNull":
wrapper.isNull(column);
break;
case "isNotNull":
wrapper.isNotNull(column);
break;
case "eq":
wrapper.eq(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "ne":
wrapper.ne(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "like":
wrapper.like(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "notLike":
wrapper.notLike(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "likeRight":
wrapper.likeRight(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "likeLeft":
wrapper.likeLeft(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "lt":
wrapper.lt(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "le":
wrapper.le(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "gt":
wrapper.gt(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "ge":
wrapper.ge(ObjectUtil.isNotEmpty(obj), column, obj);
break;
case "orderByAsc":
wrapper.orderByAsc(column);
break;
case "orderByDesc":
wrapper.orderByDesc(column);
break;
default:
break;
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
return wrapper;
}
// 获取当前类及其父类的所有字段
public static Field[] getAllDeclaredFields(Class<?> clazz) {
if (clazz == null) {
return new Field[0]; // 没有父类时返回空数组
}
// 获取当前类的所有声明字段
Field[] fields = clazz.getDeclaredFields();
// 获取父类的字段并与当前类的字段合并
Field[] parentFields = getAllDeclaredFields(clazz.getSuperclass());
// 合并当前类字段与父类字段
Field[] allFields = new Field[fields.length + parentFields.length];
System.arraycopy(fields, 0, allFields, 0, fields.length);
System.arraycopy(parentFields, 0, allFields, fields.length, parentFields.length);
return allFields;
}
}
注意:这个查询工具类,进行了修改。以前的查询工具类无法匹配继承的属性,现在的工具类是可以匹配继承的属性,具体可以查看我的文章《若依前后端分离版创建Mybatis-Plus代码生成器》。
4.5src/main/java/com/ruoyi/data/message/domain/PyMessage.java代码如下:
package com.ruoyi.data.message.domain;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.ruoyi.domain.BasePojo;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* @author py
* @date 2025-03-08
* @description 消息信息的基础类,对应数据库py_message表中的数据
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("py_message")
public class PyMessage extends BasePojo {
private static final long serialVersionUID = 1L;
// id
@TableId(value = "id", type = IdType.ASSIGN_UUID)
private String id;
// 所属人
@TableField("owner")
private String owner;
// 是否为机器人消息(0代表否,2代表是)
@TableField("bot_flag")
private String botFlag;
// 文件路径
@TableField("file_path")
private String filePath;
// 内容
// 不让springboot转义,防止html标签丢失
@TableField("text")
private String text;
// 备注
@TableField("remark")
private String remark;
}
4.6src/main/java/com/ruoyi/data/message/mapper/PyMessageMapper.java代码如下:
package com.ruoyi.data.message.mapper;
import com.ruoyi.data.message.domain.PyMessage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* @author py
* @date 2025-03-08
* @description 消息信息对应的mapper
*/
public interface PyMessageMapper extends BaseMapper<PyMessage> {
}
4.7src/main/java/com/ruoyi/data/message/service/PyMessageService.java代码如下:
package com.ruoyi.data.message.service;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.data.message.domain.PyMessage;
import com.baomidou.mybatisplus.extension.service.IService;
import reactor.core.publisher.Flux;
/**
* @author py
* @date 2025-03-08
* @description 消息信息对应的Service层接口
*/
public interface PyMessageService extends IService<PyMessage> {
AjaxResult sendMessageAndGetJsonMessage(PyMessage pyMessage);
Flux<String> sendMessageAndGetFluxMessage(PyMessage pyMessage) throws NoApiKeyException, InputRequiredException;
}
4.8src/main/java/com/ruoyi/data/message/service/impl/PyMessageServiceImpl.java代码如下:
package com.ruoyi.data.message.service.impl;
import com.alibaba.dashscope.app.ApplicationResult;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.data.message.domain.PyMessage;
import com.ruoyi.data.message.mapper.PyMessageMapper;
import com.ruoyi.data.message.service.PyMessageService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.util.AiUtil;
import io.reactivex.Flowable;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import java.util.ArrayList;
import java.util.Date;
/**
* @author py
* @date 2025-03-08
* @description 消息信息对应的Service层实现类
*/
@Service("pyMessageService")
public class PyMessageServiceImpl extends ServiceImpl<PyMessageMapper, PyMessage> implements PyMessageService {
/**
* 发送消息,并且获取json消息
*
* @param pyMessage 消息
* @return 获取的json消息
*/
@Override
public AjaxResult sendMessageAndGetJsonMessage(PyMessage pyMessage) {
ArrayList<PyMessage> list = new ArrayList<>();
list.add(pyMessage);
// 机器人消息
PyMessage botMessage = new PyMessage();
botMessage.setOwner(pyMessage.getOwner());
botMessage.setCreateBy(pyMessage.getCreateBy());
Date date = new Date();
botMessage.setCreateTime(date);
botMessage.setBotFlag("2");
String text = "";
try {
// 如果没有文件,调用一般文生文接口
if (StringUtils.isEmpty(pyMessage.getFilePath())) {
text = AiUtil.getTyQwMessage(pyMessage.getText());
}
} catch (Exception e) {
text = "机器人回复异常";
} finally {
botMessage.setText(text);
list.add(botMessage);
if (saveBatch(list)) {
return AjaxResult.success(botMessage);
}
return AjaxResult.error("保存消息失败");
}
}
/**
* 通过流发送消息
* @param pyMessage 消息
* @return
* @throws NoApiKeyException
* @throws InputRequiredException
*/
@Override
public Flux<String> sendMessageAndGetFluxMessage(PyMessage pyMessage) throws NoApiKeyException, InputRequiredException {
// 保存消息到本地
save(pyMessage);
// 机器人消息
Flowable<ApplicationResult> result = AiUtil.getTyQwFluxMessage(pyMessage.getText());
return Flux.from(result)
.map(data -> data.getOutput().getText())
.publishOn(Schedulers.boundedElastic());
}
}
4.9src/main/java/com/ruoyi/data/message/controller/PyMessageController.java代码如下:
package com.ruoyi.data.message.controller;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Date;
import javax.servlet.http.HttpServletResponse;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.util.QueryWrapperUtil;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.data.message.domain.PyMessage;
import com.ruoyi.data.message.service.PyMessageService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import reactor.core.publisher.Flux;
/**
* @author py
* @date 2025-03-08
* @description 消息信息对应的controller层
*/
@RestController
@RequestMapping("/py/message")
public class PyMessageController extends BaseController {
@Autowired
private PyMessageService pyMessageService;
/**
* 分页查询消息信息列表
*/
@GetMapping("/list")
@PreAuthorize("@ss.hasPermi('py:message:message:list')")
public TableDataInfo list(PyMessage pyMessage) {
startPage();
Wrapper<PyMessage> wrapper = QueryWrapperUtil.getWrapper(pyMessage);
List<PyMessage> list = pyMessageService.list(wrapper);
return getDataTable(list);
}
/**
* 查询消息信息列表
*/
@GetMapping("/getList")
@PreAuthorize("@ss.hasPermi('py:message:message:list')")
public AjaxResult getList(PyMessage pyMessage) {
pyMessage.setOwner(getUsername());
QueryWrapper<PyMessage> wrapper = QueryWrapperUtil.getWrapper(pyMessage);
wrapper.orderByAsc("create_time");
wrapper.orderByAsc("bot_flag");
List<PyMessage> list = pyMessageService.list(wrapper);
return success(list);
}
/**
* 导出消息信息列表
*/
@PostMapping("/export")
@PreAuthorize("@ss.hasPermi('py:message:message:export')")
@Log(title = "消息信息", businessType = BusinessType.EXPORT)
public void export(HttpServletResponse response, PyMessage pyMessage) {
Wrapper<PyMessage> wrapper = QueryWrapperUtil.getWrapper(pyMessage);
List<PyMessage> list = pyMessageService.list(wrapper);
ExcelUtil<PyMessage> util = new ExcelUtil<PyMessage>(PyMessage.class);
util.exportExcel(response, list, "消息信息数据");
}
/**
* 获取消息信息详细信息
*/
@PreAuthorize("@ss.hasPermi('py:message:message:query')")
@GetMapping(value = "/{messageId}")
public AjaxResult getInfo(@PathVariable String messageId) {
return success(pyMessageService.getById(messageId));
}
/**
* 新增消息信息,字符串方式,并返回机器人消息
*/
@PreAuthorize("@ss.hasPermi('py:message:message:add')")
@PostMapping("/send")
public AjaxResult send(@RequestBody PyMessage pyMessage) {
String username = getUsername();
pyMessage.setCreateBy(username);
pyMessage.setOwner(username);
return pyMessageService.sendMessageAndGetJsonMessage(pyMessage);
}
/**
* 添加消息
*
* @param pyMessage 消息
* @return
*/
@PreAuthorize("@ss.hasPermi('py:message:message:add')")
@PostMapping
public AjaxResult add(@RequestBody PyMessage pyMessage) {
try {
pyMessage.setText(URLDecoder.decode(pyMessage.getText(), String.valueOf(StandardCharsets.UTF_8)));
} catch (UnsupportedEncodingException e) {
return AjaxResult.error();
}
String username = getUsername();
pyMessage.setCreateBy(username);
pyMessage.setOwner(username);
return toAjax(pyMessageService.save(pyMessage));
}
/**
* 新增消息,通过流的方式获取机器人消息
*
* @param pyMessage
* @return
*/
@PreAuthorize("@ss.hasPermi('py:message:message:add')")
@PostMapping(value = "/getFlux", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> getFlux(@RequestBody PyMessage pyMessage) {
String username = getUsername();
pyMessage.setCreateBy(username);
pyMessage.setOwner(username);
try {
Flux<String> flux = pyMessageService.sendMessageAndGetFluxMessage(pyMessage);
return flux;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 修改消息信息
*/
@PreAuthorize("@ss.hasPermi('py:message:message:edit')")
@Log(title = "消息信息", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PyMessage pyMessage) {
pyMessage.setUpdateBy(getUsername());
pyMessage.setUpdateTime(new Date());
return toAjax(pyMessageService.updateById(pyMessage));
}
/**
* 删除消息信息
*/
@PreAuthorize("@ss.hasPermi('py:message:message:remove')")
@Log(title = "消息信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{messageIds}")
public AjaxResult remove(@PathVariable String[] messageIds) {
return toAjax(pyMessageService.removeByIds(Arrays.asList(messageIds)));
}
}
10.启动后端。
3.前端配置
1.根据文件夹结构,新建src\py\api\message\message.js文件,并用以下代码替换。
import request from '@/utils/request';
let messageUrl = "/py/message";
// 分页查询消息信息列表
export function listMessage(query) {
return request({
url: messageUrl + '/list',
method: 'get',
params: query
})
}
// 不分页查询消息信息列表
export function getMessageList(query) {
return request({
url: messageUrl + '/getList',
method: 'get',
params: query
})
}
// 查询消息信息详细
export function getMessage(id) {
return request({
url: messageUrl + '/' +id,
method: 'get'
})
}
// 发送消息并获取json机器人小
export function sendMessage(data) {
return request({
url: messageUrl + '/send',
method: 'post',
data: data
})
}
// 新增消息信息
export function addMessage(data) {
return request({
url: messageUrl,
method: 'post',
data: data
})
}
// 修改消息信息
export function updateMessage(data) {
return request({
url: messageUrl,
method: 'put',
data: data
})
}
// 删除消息信息
export function delMessage(ids) {
return request({
url: messageUrl + '/' + ids,
method: 'delete'
})
}
2.根据文件夹结构,新建src\components\py\tooltip\index.vue文件,并用以下代码替换。
<template>
<el-tooltip ref="tooltip" :popper-class="currentPopperClass" :content="content" :placement="placement"
:value="currentValue" @input="updateValue" :disabled="disabled" :offset="offset" :transition="transition"
:visible-arrow="visibleArrow" :popper-options="popperOptions" :open-delay="openDelay" :manual="manual"
:enterable="enterable" :hide-after="hideAfter" :tabindex="tabindex">
<slot @mouseenter="showTooltip" @mouseleave="hideTooltip"></slot>
</el-tooltip>
</template>
<script>
export default {
props: {
// 新增属性
// 样式,可以设置设置下面三个值
popperStyle: {
type: Object,
default() {
return {
// 背景色
backgroundColor: "#409eff",
// 字体颜色
color: "#fff",
// 字体大小
fontSize: "14px"
}
}
},
// el-tooltip原有属性
value: Boolean,
openDelay: {
type: Number,
default: 0
},
disabled: Boolean,
manual: Boolean,
// 删除属性,通过上面新增属性,样式设置
// effect: {
// type: String,
// default: 'dark'
// },
arrowOffset: {
type: Number,
default: 0
},
popperClass: String,
content: String,
visibleArrow: {
default: true
},
transition: {
type: String,
default: 'el-fade-in-linear'
},
popperOptions: {
default() {
return {
boundariesPadding: 10,
gpuAcceleration: false
};
}
},
enterable: {
type: Boolean,
default: true
},
hideAfter: {
type: Number,
default: 0
},
tabindex: {
type: Number,
default: 0
},
offset: {
default: 0
},
placement: {
type: String,
default: 'bottom'
},
},
data() {
return {
uniqueId: `py-tooltip-${Math.random().toString(36).substr(2, 9)}`
}
},
computed: {
// 当前值
currentValue: {
get() {
return this.value;
},
set(val) {
this.updateValue(val);
}
},
// 当前样式
currentPopperStyle() {
return {
backgroundColor: this.popperStyle.backgroundColor || "#409eff",
color: this.popperStyle.color || "#fff",
fontSize: this.popperStyle.fontSize || "14px"
};
},
// 当前样式
currentPopperClass() {
return this.popperClass ? (this.uniqueId + ' py-tooltip-popper ' + this.popperClass) : (this.uniqueId + ' py-tooltip-popper');
}
},
watch: {
value(newVal, oldVal) {
this.currentValue = newVal;
},
placement(newVal) {
// 获取所有 <style> 标签
const styleTags = document.head.getElementsByTagName("style");
let hasStyle = false;
// 遍历所有 <style> 标签
for (let styleTag of styleTags) {
if (styleTag.textContent.includes(`.${this.uniqueId}[x-placement^="${this.placement}"] .popper__arrow::after`)) {
hasStyle = true;
break;
}
}
// 如果没有新增
if (!hasStyle) {
const style = document.createElement("style");
style.innerHTML = `
.${this.uniqueId}[x-placement^="${this.placement}"] .popper__arrow::after {
border-${this.placement}-color: ${this.currentPopperStyle.backgroundColor} !important;
}
.${this.uniqueId}[x-placement^="${this.placement}"] .popper__arrow {
border-${this.placement}-color: ${this.currentPopperStyle.backgroundColor} !important;
}
`;
document.head.appendChild(style);
}
}
},
mounted() {
// 在组件挂载后,将样式动态插入到 style 标签中
const style = document.createElement("style");
style.innerHTML = `
.${this.uniqueId} {
background-color: ${this.currentPopperStyle.backgroundColor} !important;
color: ${this.currentPopperStyle.color} !important;
font-size: ${this.currentPopperStyle.fontSize} !important;
}
.${this.uniqueId}[x-placement^="${this.placement}"] .popper__arrow::after {
border-${this.placement}-color: ${this.currentPopperStyle.backgroundColor} !important;
}
.${this.uniqueId}[x-placement^="${this.placement}"] .popper__arrow {
border-${this.placement}-color: ${this.currentPopperStyle.backgroundColor} !important;
}
`;
document.head.appendChild(style);
},
methods: {
showTooltip() {
this.currentValue = true; // 鼠标进入时显示提示
},
hideTooltip() {
this.currentValue = false; // 鼠标离开时隐藏提示框
},
// // 更新值
updateValue(val) {
this.$emit("input", val);
},
},
}
</script>
<style>
/* 统一样式 */
.py-tooltip-popper {
/* 内边距 */
padding: 8px 12px !important;
/* 圆角 */
border-radius: 6px !important;
/* 阴影 */
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2) !important;
}
</style>
注意:
由于原来的el-tooltip不太好看,封装了个py-tooltip组件,如果有需要可以使用此组件。此组件会操作document,请根据实际情况使用,这也是没办法,因为el-tooltip使用vue-popper,只能操作document设置样式。并且,为了防止样式的相互干扰,每使用一个py-tooltip会动态生成一个样式。因此,使用py-tooltip最好通过v-show来控制显示和隐藏,避免产生过多的样式。
3.根据文件夹结构,新建src\views\py\chat\index.vue文件,并用以下代码替换。
<template>
<div class="py-chat">
<!-- 悬浮按钮,打开聊天框 -->
<py-tooltip content="智能客服" placement="left">
<el-button v-show="!chatVisible" class="chat-button" type="primary" icon="el-icon-message" circle
@click="toggleChat"></el-button>
</py-tooltip>
<!-- 自定义聊天对话框 -->
<el-dialog :visible.sync="chatVisible" :fullscreen="isFullScreen" :modal-append-to-body="false"
class="chat-dialog" :show-close="false">
<!-- 自定义标题栏 -->
<template slot="title">
<div class="chat-header">
<span class="chat-title">AI 智能客服</span>
<div class="chat-controls">
<!-- :placement="isFullScreen ? 'bottom' : 'top'" -->
<py-tooltip :content="isFullScreen ? '最小化' : '全屏'" :placement="isFullScreen ? 'bottom' : 'top'">
<el-button type="text" @click="toggleFullScreen">
<i :class="isFullScreen ? 'el-icon-minus' : 'el-icon-full-screen'"></i>
</el-button>
</py-tooltip>
<py-tooltip content="关闭" :placement="isFullScreen ? 'bottom' : 'top'">
<el-button type="text" @click="toggleChat">
<i class="el-icon-close"></i>
</el-button>
</py-tooltip>
</div>
</div>
</template>
<!-- 聊天内容 -->
<div class="chat-content" ref="chatContent" :style="{height: chatContentHeight}">
<div v-for="(message, index) in messages" :key="index" class="chat-message"
:class="message.botFlag == '2' ? 'bot' : 'user'">
<!-- AI 头像 -->
<img v-if="message.botFlag == '2'" class="chat-avatar" :src="botAvatar" alt="AI">
<!-- 聊天气泡 -->
<div class="chat-bubble">
<div class="chat-text" v-html="message.text">
</div>
<div class="chat-time" v-if="message.createTime">
{{message.createTime}}
</div>
</div>
<!-- 用户头像 -->
<img v-if="message.botFlag == '0'" class="chat-avatar" :src="avatar" alt="用户">
</div>
</div>
<!-- 输入框 -->
<div class="chat-input">
<!-- 文件上传按钮 -->
<el-upload class="upload-container" :action="upload.url" :headers="upload.headers"
:disabled="upload.isUploading" :show-file-list="false" :on-progress="handleUploadProgress"
:on-success="handleUploadSuccess" :before-upload="beforeUpload" multiple>
<el-button type="primary" icon="el-icon-upload" class="custom-upload-btn">上传文件</el-button>
</el-upload>
<!-- 显示上传文件及进度 -->
<div class="file-list">
<div v-for="file in fileList" :key="file.uid" class="file-item">
<span class="file-name">{{ file.name }}</span>
<el-progress v-if="file.progress < 100" :percentage="file.progress" type="circle" :width="40">
</el-progress>
<span v-else class="file-list-operation">
<py-tooltip content="删除" placement="top">
<i class="el-icon-close file-remove" @click="handleUploadRemove(file)"></i>
</py-tooltip>
</span>
</div>
</div>
<el-input v-model="userInput" type="textarea" clearable
:autosize="{ minRows: 4, maxRows: 6 }"></el-input>
<div class="chat-input-button">
<el-button type="info" plain icon="el-icon-circle-close" @click="chatVisible=false;">取消</el-button>
<el-button type="primary" plain icon="el-icon-position" @click="sendMessage">发送</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import PyTooltip from "@/components/py/tooltip";
import { addMessage, getMessageList, sendMessage } from "@/py/api/message/message";
import { getToken } from "@/utils/auth";
import { tansParams } from "@/utils/ruoyi";
import axios from 'axios';
export default {
data() {
return {
// 智能按钮提示框是否显示
chatButtonTooltipShow: false,
// 改变大小按钮提示框是否显示
resizeButtonTooltipShow: false,
// 关闭按钮提示框是否显示
closeButtonTooltipShow: false,
chatVisible: false, // 是否显示聊天框
isFullScreen: false, // 是否全屏
userInput: "", // 用户输入
// ai头像
botAvatar: require("@/assets/py/images/ai.png"),
// 聊天内容高度
chatContentHeight: "300px",
messages: [],
// 上传参数
upload: {
// 是否禁用上传
isUploading: false,
// 设置上传的请求头部
headers: { Authorization: "Bearer " + getToken() },
// 上传的地址
url: process.env.VUE_APP_BASE_API + "/common/upload"
},
// 文件列表
fileList: [],
// 流内容中断标志
fluxInterrupt: false
};
},
created() {
// 获取所有消息
this.getList();
},
components: {
PyTooltip
},
computed: {
...mapGetters([
// 用户头像
'avatar'
]),
},
methods: {
// 获取所有消息
getList() {
getMessageList().then(response => {
this.messages = response.data;
if (!this.messages.length) {
this.messages.push({ botFlag: "2", text: "你好!我是智能客服,有什么可以帮助你的吗?" });
}
})
},
// 显示/隐藏聊天框
toggleChat() {
this.chatVisible = !this.chatVisible;
// 如果打开聊天框,切换到最下方
if (this.chatVisible) {
this.scrollToBottom();
}
},
// 一次性返回所有数据的方式
// 等在时间太长,不推荐使用
// 只支持一问一答的方式,不支持文件上传等操作
// 开始测试使用,后面功能不会开发,需要自己参考流的方式进行功能完善
// sendMessage() {
// if (!this.userInput.trim()) return;
// let userInput = this.userInput;
// this.userInput = "";
// let currentDate = new Date();
// let createTime = this.parseTime(currentDate);
// this.messages.push({ createTime: createTime, botFlag: '0', text: userInput });
// this.scrollToBottom();
// this.messages.push({ botFlag: '2', text: "正在思考中..." });
// sendMessage({ createTime: createTime, text: userInput }).then(response => {
// this.$set(this.messages, this.messages.length - 1, response.data);
// })
// },
// 优化消息显示
optimizeMessage() {
let messages = this.messages;
let messagesLength = messages.length;
let currentMessage = messages[messagesLength - 1];
let currentMessageText = currentMessage.text;
currentMessageText = currentMessageText
.replace(/---/g, "<hr>") // 替换横线
.replace(/(#{3,})([^\*]*)\*\*([^\*]*)\*\*/g, "<h3><strong>$2$3</strong></h3>") // 替换h3
.replace(/-\s*\*\*([^\*]*)\*\*:/g, "<strong>$1</strong><br>") // 加粗,并换行
.replace(/-\s*\*\*([^\*]*):\*\*/g, "<strong>$1</strong><br>") // 加粗,并换行
.replace(/\*\*([^\n\*]+)\*\*/g, "<strong>$1</strong>") // 非换行加粗
.replace(/。(<strong>[^<\n\s]+<\/strong><br>)/g, "。<br>$1") // 在句号后面的加粗语句添加换行
// .replace(/- ((?:(?!<br>)[^-])+)/g, "<li>$1</li>") // 添加li
// .replace(/<\/li><li>/g, "</li><br><li>") // 两个li之间添加换行
.replace(/(。)\s+(<strong>)/g, "$1<br>$2") // 特殊情况,分行失败重新分行
.replace(/#{3,} (总结)/g, "<h3><strong>$1</strong></h3>") // 特殊处理,如果有总结,添加h3和加粗
.replace(/\n/g, "<br>"); // 换行
this.$set(currentMessage, "text", currentMessageText);
},
// 获取所有消息之后优化
optimizeMessageAfterEnd() {
let messages = this.messages;
let messagesLength = messages.length;
let currentMessage = messages[messagesLength - 1];
let currentMessageText = currentMessage.text;
currentMessageText = currentMessageText
.replace(/- ((?:(?!<br>)(?!<hr>)(?!<h3>)(?!- ).)+)/g, "<li>$1</li>") // 添加li
.replace(/<\/li><li>/g, "</li><br><li>") // 两个li之间添加换行
.replace(/(。)\s*(<li>)/g, "$1<br>$2") // 增加li和内容之间的换行
.replace(/(<br><strong>(?:(?!(<\/strong>)).)*<\/strong>)/g, "<br>$1") // 在换行加粗前面,再增加一个换行
.replace(/(<strong>(?:(?!(<\/strong>)).)*<\/strong><br>)/g, "$1<br>") // 在加粗并换行后面,再添加一个换行
.replace(/(。)(<br>)(?:(?!<br>)(?!<))/g, "$1<br>$2"); // 再次检查一下分行失败的情况
this.$set(currentMessage, "text", currentMessageText);
},
// 获取句号后面回车数量
getFullStopAfterEnterCount(text) {
let count = 0;
let lastFullStopIndex = text.lastIndexOf("。");
let nextEnterIndex = -1;
if (text[lastFullStopIndex + 1] == "\n") {
nextEnterIndex = lastFullStopIndex + 1;
} else {
return count;
}
// 如果回车后面还有内容直接返回
if (text.substring(nextEnterIndex + 1).match(/[^\s\n]/g)) {
return count;
}
while (nextEnterIndex != -1) {
count++;
if ((nextEnterIndex < text.length - 1) && (text[nextEnterIndex + 1] == "\n")) {
nextEnterIndex = nextEnterIndex + 1;
} else {
nextEnterIndex = -1;
}
}
return count;
},
// 获取开始的回车数量
getFirstEnterCount(text) {
let count = 0;
let nextEnterIndex = -1;
if (text[0] == "\n") {
nextEnterIndex = 0;
} else {
return count;
}
while (nextEnterIndex != -1) {
count++;
if ((nextEnterIndex < text.length - 1) && (text[nextEnterIndex + 1] == "\n")) {
nextEnterIndex = nextEnterIndex + 1;
} else {
nextEnterIndex = -1;
}
}
return count;
},
// 通过流的方式获取数据,不需要等待
// 生成数据后,再次调用后端,才能保存。
async sendMessage() {
this.fluxInterrupt = false;
if (!this.userInput.trim()) return;
let userInput = this.userInput;
this.userInput = "";
let currentDate = new Date();
let createTime = this.parseTime(currentDate);
// 替换掉emoji表情,因为我mysql版本较低,不支持保存此类表情
userInput = userInput.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '');
this.messages.push({ createTime, botFlag: '0', text: userInput });
let filePath = this.fileList.map(item => item.filePath).join(",");
this.fileList = [];
this.scrollToBottom();
this.messages.push({ botFlag: '2', text: "" });
let obj = {
createTime,
text: userInput,
filePath
}
let headers = {
"Authorization": "Bearer " + getToken(),
"Content-type": "application/json;charset=utf-8"
}
try {
const response = await fetch(process.env.VUE_APP_BASE_API + "/py/message/getFlux",
{
method: "post",
responseType: "stream",
headers: headers,
body: JSON.stringify(obj),
}
)
const reader = response.body.getReader();
const decoder = new TextDecoder();
// 是否有句号
let hasFullStop = false;
// 句号后面的回车数量
let fullStopAfterEnterCount = 0;
let notOptimizeText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
let messages = this.messages;
let messagesLength = messages.length;
let currentMessage = messages[messagesLength - 1];
let currentMessageText = currentMessage.text;
let currentText = decoder.decode(value, { stream: true });
let firstEnterCount = 0;
let addEnter = false;
// 如果上段内容有句号,判断是否需要加回车。
if (hasFullStop) {
firstEnterCount = this.getFirstEnterCount(currentText);
if (fullStopAfterEnterCount + firstEnterCount > 2) {
addEnter = true;
}
// 重置
fullStopAfterEnterCount = 0;
hasFullStop = false;
}
// 如果有句号
if (currentText.includes("。")) {
fullStopAfterEnterCount = this.getFullStopAfterEnterCount(currentText);
if (fullStopAfterEnterCount > 2) {
addEnter = true;
fullStopAfterEnterCount = 0;
} else {
hasFullStop = true;
}
}
// 有实际数据时,才添加
if (currentText.match(/[^\n\s]/g)) {
// 特殊情况"data:。"连在一起时,替换为换行
currentText = currentText.replace("data:。", "。<br>");
// 删除所有的回车
currentText = currentText.replace(/\n/g, "");
// 删除标志
currentText = currentText.replace(/data:/g, "");
if (addEnter) {
currentText += "<br>";
}
if (currentText) {
notOptimizeText += currentText;
currentMessage.text = currentMessageText + currentText;
this.$set(this.messages, messagesLength - 1, currentMessage);
}
} else if (addEnter) {
currentMessage.text = currentMessageText + "<br>";
this.$set(this.messages, messagesLength - 1, currentMessage);
}
// 优化内容显示
this.optimizeMessage();
this.scrollToBottom();
}
console.log(notOptimizeText);
// 获取所有消息之后优化内容
this.optimizeMessageAfterEnd();
this.scrollToBottom();
let messages = this.messages;
let messagesLength = messages.length;
let currentBotMessage = messages[messagesLength - 1];
// 替换掉emoji表情,因为我mysql版本较低,不支持保存此类表情
currentBotMessage.text = currentBotMessage.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '');
currentBotMessage["createTime"] = this.parseTime(new Date());
console.log(currentBotMessage.text);
this.$set(this.messages, messagesLength - 1, currentBotMessage);
// 防止后端将html标签转义
let copyCurrentBotMessage = JSON.parse(JSON.stringify(currentBotMessage));
copyCurrentBotMessage.text = encodeURIComponent(copyCurrentBotMessage.text)
// 保存数据
addMessage(copyCurrentBotMessage);
} catch (error) {
console.log(error);
this.$modal.msgError("生成消息失败")
}
},
// 滚动到底部
scrollToBottom() {
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
chatContent.scrollTop = chatContent.scrollHeight;
});
},
// 切换全屏
toggleFullScreen() {
this.isFullScreen = !this.isFullScreen;
if (this.isFullScreen) {
this.chatContentHeight = "60vh";
} else {
this.chatContentHeight = "300px";
}
this.scrollToBottom();
},
// 上传前,添加文件到列表
beforeUpload(file) {
this.fileList.push({
uid: file.uid,
name: file.name,
progress: 0
});
return true; // 允许上传
},
// 处理上传进度
handleUploadProgress(event, file) {
this.upload.isUploading = true;
const targetFile = this.fileList.find(f => f.uid === file.uid);
if (targetFile) {
targetFile.progress = Math.round((event.loaded / event.total) * 100);
}
},
// 上传成功
handleUploadSuccess(response, file) {
this.upload.isUploading = false;
const targetFile = this.fileList.find(f => f.uid === file.uid);
if (targetFile) {
targetFile.progress = 100; // 上传成功,进度变为100%
// 保存文件上传路径
if (response.code == 200) {
targetFile["filePath"] = response.fileName;
}
}
},
// 处理上传列表删除操作
handleUploadRemove(file) {
this.fileList = this.fileList.filter(item => item.uid != file.uid);
}
}
};
</script>
<style lang="scss" scoped>
.py-chat {
/* 悬浮按钮 */
.chat-button {
position: fixed;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
font-size: 22px;
z-index: 1000;
}
/* 聊天对话框 */
.chat-dialog {
/* 自定义标题栏 */
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
/* 聊天标题 */
.chat-title {
font-size: 18px;
font-weight: bold;
text-align: center;
flex-grow: 1;
}
/* 控制按钮 */
.chat-controls {
display: flex;
.el-button {
font-size: 18px;
}
}
}
/* 聊天内容 */
.chat-content {
height: 300px;
overflow-y: auto;
padding: 10px;
background: #f5f5f5;
border-radius: 5px;
font-size: 16px;
/* 聊天消息气泡 */
.chat-message {
display: flex;
margin: 10px 0;
/* 所有聊天气泡样式 */
.chat-bubble {
padding: 8px 15px;
border-radius: 8px;
background: #409eff;
color: #fff;
max-width: 70%;
word-wrap: break-word;
/* 聊天内容 */
.chat-text {
white-space: pre-wrap;
text-align: left;
/* 字过长自动换行 */
word-break: break-word;
}
/* 聊天时间 */
.chat-time {
margin-top: 10px;
font-size: 12px;
color: #F0F0ED;
}
}
/* 机器人聊天样式 */
&.bot {
justify-content: flex-start;
/* 聊天气泡 */
.chat-bubble {
background: #e0e0e0;
color: #333;
/* 聊天时间 */
.chat-time {
color: #606266;
}
}
}
/* 用户聊天样式 */
&.user {
justify-content: flex-end;
}
/* 头像样式 */
.chat-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
margin: 0 10px;
}
}
}
/* 输入框 */
.chat-input {
margin-top: 10px;
/* 自定义上传按钮 */
.custom-upload-btn {
background-color: #409eff;
color: white;
border-radius: 20px;
padding: 10px 20px;
}
/* 上传文件列表 */
.file-list {
margin-top: 10px;
.file-item {
display: flex;
align-items: center;
margin-bottom: 10px;
.file-name {
flex: 1;
font-size: 14px;
}
/* 文件列表操作 */
.file-list-operation {
font-size: 20px;
/* 文件删除 */
.file-remove {
cursor: pointer;
color: #F56C6C;
margin-right: 10px;
}
/* 文件完成 */
.file-done {
color: #67C23A;
}
}
/* 进度条样式 */
.el-progress {
margin-left: 10px;
}
}
}
.el-textarea {
margin-right: 10px;
}
.chat-input-button {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
}
}
}
</style>
4.打开src\layout\components\AppMain.vue文件,添加聊天组件,这样能保证所有页面,都显示聊天按钮。
import PyChat from "@/views/py/chat/index"
4.功能介绍
1.通过流的方式获取数据,自动转换格式。
2.样式转换分为两种,一种是获取流数据过程中转换样式。另一种是数据获取完成后,对数据进行统一转换。样式转化使用添加html标签的方式,如果有特殊样式需求,可以根据实际情况修改。
3.支持关闭、最小化和全屏三种状态。关闭状态和全屏状态上面已经显示,下面是最小化状态。
4.支持文件上传和删除,虽然没有使用到文件。但是能将文件地址保存到数据库中,多文件通过“,”分割。如果不需要此功能,可以将相关代码删除。
5.头像和用户头像一致,修改后会同步修改。机器人头像指向src\assets\py\images\ai.png文件,如果有需要可以修改。
5.代码说明
1.注释掉的sendMessage函数是一次性获取数据后,再推给前端。此方式等待时间较长,不推荐使用。如果有此需求,可以去掉注释。
2.由于我的mysql版本较低,数据库不支持报错emoji表情,因此我将表情相关的内容替换掉。如果数据库版本支持emoji表情,可以将这部分代码去掉。
3.由于直接将html字符串传到后端,会将html的标签进行转义。为了防止html标签被转义,先前端加密,再后端解密。
4.当数据较多时,通义千问会自动将内容进行分条。一旦分条后,就要涉及样式转换,通过optimizeMessage和optimizeMessageAfterEnd两个函数完成。optimizeMessage函数用于正在获取数据过程中的样式转换,optimizeMessageAfterEnd用于数据获取完成后的样式转换。这是没办法通过一个函数完成的,因为li样式就用-表示,但是有些样式也包含-,如果都在获取数据过程中完成,就容易把很多内容当成li,导致样式混乱。因此,第一个函数处理获取数据过程中的样式,不至于非常难看;第二个函数处理数据完成后的样式,也是最终样式。
5.控制台会打印准换之前和转换后数据,用于判断是否转换出错。因为通过流获取数据,很容易出现问题。我已经测试了很多数据,发现样式没有大改变。如果有特殊情况,请根据打印信息修改上面两个函数。如果不需要打印,可以删除相关代码。
6.由于类按照类静态属性加载、配置加载和类注入的顺序依次完成,静态变量直接取yml配置一定为空。因此,新增了ConfigUtil工具类,处理静态属性获取yml配置问题。原理是,ConfigUtil工具类非静态属性取到yml配置信息,然后通过@PostConstruct注解,实现类注入时自动调静态变量赋值方法完成上述功能。
6.总结
如果有细心的小伙伴可以发现,集成ai的过程中,发送和接收数据并不麻烦。主要是接收数据后,对数据进行处理。不论是为了减少代码还是提高效率,都需要用到正则表达式。就那几行替换代码,才是文章的精髓之处。虽然这次复制粘贴就可以完成功能,但是下次再用到正则表达式又不知道怎么办了。为了防止上面问题出现,我后续会发布一篇关于正则表达式的文章,说实话,我以前也看到这玩意头疼,一般都是用现成的。但是,真正会用了后,发现这东西也没有那么难,后续,我会详细介绍下如何使用正则表达式。
如果本文章对您有帮助并且条件允许的话,可以给我打赏下。不打赏也没关系,您可以给我点赞支持下,您的支持就是我最大的动力。我会不定时发布一些关于若依框架、java、Vue、uniapp等方面的内容,如果感兴趣的话,可以关注我。如果您需要前后端分离版的文件管理系统、流程管理系统或其他以上四方面涉及的内容,查看我的主页一定不后悔。