本周主要完成了AI面试官聊天系统的核心功能开发,重点实现了以下功能模块:
我们采用树形结构管理对话分支,每个分支节点包含:
{
branchId: String, // 分支唯一ID
index: Number, // 分支索引
parentBranchIndex: Number, // 父分支索引
children: Array, // 子分支数组
messageLocals: Array // 当前分支消息列表
}
分支操作逻辑
节点的切割(用于产生新的分支):
创造一个空节点作为辅助根节点
对一个message进行产生分支操作时:
判断message顺序:
直接根据message在数组中的索引确定,因为所有的操作都可以保证messagelist逻辑上的顺序
如果自己是这个分支的第一个message
创建brach,其parentId为自己的parentId,并在父branch的children中加入新增的branchId,再在新增的branch中加入分支操作产生的message以及后续的message
同步记录兄弟的branchId
如果不是第一个message
需要分割brach产生新的节点:
async newChatBranch(index) {
try {
// 情况1:是分支的第一个消息(index为0)
if (index == 0) {
const newBranch = {
branchId: this.generateUuid(),
chatId: this.chatRecordId,
index: this.allBranches.length,
parentBranchIndex: this.currentBranch.parentBranchIndex,
children: [],
messageLocals: []
};
// 添加到分支列表
this.allBranches.push(newBranch);
// 在父分支的children中添加新分支
const parentBranch = this.allBranches.find(
b => b.index == this.currentBranch.parentBranchIndex
);
if (parentBranch) {
parentBranch.children.push({
branchIndex: newBranch.index,
tag: `分支${parentBranch.children.length + 1}`
});
}
// 切换当前分支
this.currentBranch = newBranch;
this.modifiedBranch.push(parentBranch);//先不加入新增的branch,到后面消息接受完毕后再更新
}
// 情况2:不是第一个消息
else {
// 创建新父分支(包含index之前的消息)
const newParentBranch = {
branchId: this.generateUuid(),
chatId: this.chatRecordId,
index: this.allBranches.length,
parentBranchIndex: this.currentBranch.parentBranchIndex,
children: [
{
branchIndex: this.currentBranch.index, // 新index分配给当前分支
tag: '原分支'
}
],
messageLocals: []
};
newParentBranch.messageLocals = this.currentBranch.messageLocals.slice(0, index).map(msg => ({
...msg,
branchId: newParentBranch.branchId // 更新branchId
}))
// 找到newParentBranch的父分支
const grandParentBranch = this.allBranches.find(
b => b.index == newParentBranch.parentBranchIndex
);
if (grandParentBranch) {
// 遍历父分支的children数组
grandParentBranch.children.forEach(child => {
if (child.branchIndex == this.currentBranch.index) {
// 将当前分支的引用改为新父分支
child.branchIndex = newParentBranch.index;
}
});
}
// 将当前分支从index开始的message分配给新分支
this.currentBranch.messageLocals = this.currentBranch.messageLocals.slice(index).map(msg => ({
...msg,
branchId: this.currentBranch.branchId // 保持当前branchId
}));
this.currentBranch.parentBranchIndex = newParentBranch.index;
// 创建新子分支(包含index及之后的消息)
const newChildBranch = {
branchId: this.generateUuid(),
chatId: this.chatRecordId,
index: this.allBranches.length + 1,
parentBranchIndex: newParentBranch.index,
children: [],
messageLocals: []
};
newParentBranch.children.push({
branchIndex: newChildBranch.index,
tag: `分支${newParentBranch.children.length + 1}`
});
// 添加到分支列表
this.allBranches.push(newParentBranch, newChildBranch);
//新建的分支在正式接受到信息之后再保存
this.modifiedBranch.push(newParentBranch, this.currentBranch,grandParentBranch);
// 切换当前分支到新创建的子分支
this.currentBranch = newChildBranch;
}
//console.log(this.currentBranch)
// 重新构建经过currentBranch的路径
// console.log(this.branchPath)
await this.buildPathForTargetBranch(this.currentBranch);
} catch (error) {
console.error('创建分支失败:', error);
this.$message.error('创建分支失败');
}
},
async buildPathForTargetBranch(targetBranch) {
//如果不存在(如新建分支时接受信息,则使用兄弟分支)
if (!targetBranch) return;
// 1. 向上查找父分支链
const parentChain = [];
let current = targetBranch;
while (current && current.parentBranchIndex !== -1) {
const parent = this.allBranches.find(b => b.index == current.parentBranchIndex);
if (parent) {
parentChain.unshift(parent); // 添加到数组开头
current = parent;
} else {
break;
}
}
// 2. 从根分支开始向下构建路径
// this.branchPath = []; // 重置分支路径
this.siblingNodes = [];
this.messageListForShow = []; // 清空消息展示列表
this.processedFiles = [];
let parentBranch = this.rootBranch;
// 遍历父链中的每个分支
for (const branch of parentChain) {
if (branch) {
if(branch.index != 0){
// 获取当前分支的所有消息
const branchMessages = branch?.messageLocals || [];
// 将消息添加到展示列表中
this.messageListForShow.push(...branchMessages);
this.siblingNodes.push({
index: branch.index,
branchId:branch.branchId,
siblings: parentBranch.children.map(child => ({
index: child.branchIndex,
tag: child.tag
}))
})
}
parentBranch = branch;
}
}
this.siblingNodes.push({
index: targetBranch.index,
branchId:targetBranch.branchId,
siblings: parentBranch.children.map(child => ({
index: child.branchIndex,
tag: child.tag
}))
})
// 3. 添加目标以及之后的分支到路径
await this.buildBranchPath(targetBranch);
},
完整信息的展示
输入目标节点分支,向上通过父节点索引找到到根节点的完整路径(具体实现如上面代码所示),向下默认展示每个节点的第一个孩子直到叶节点组成的路劲
//按照默认方式构建分支路径(没有指定需要经过哪个节点)
async buildBranchPath(startBranch) {
this.currentBranch = startBranch;
let currentBranch = startBranch;
while (currentBranch) {
// 添加当前分支的消息
if (currentBranch.messageLocals?.length) {
this.messageListForShow.push(...currentBranch.messageLocals);
}
// 获取当前分支的选择
// let childIndex = this.branchPath.find(p => p.index === currentBranch.index)?.index
// 如果没有选择,则选择第一个分支
let childIndex = -1
if(currentBranch.children && currentBranch.children.length > 0){
childIndex = currentBranch.children[0].branchIndex
// console.log(childIndex)
}else{
//到达叶子节
childIndex = -1; // 没有子分支时设为-
this.branchPath.push({
index: currentBranch.index,
childIndex
})
break; // 退出循环
}
let child = this.allBranches.find(p => p.index == childIndex);
//以当前节点添加其子节点的兄弟信息
this.siblingNodes.push({
index: childIndex,
branchId: child?.branchId,
siblings: currentBranch.children.map(child => ({
index: child.branchIndex,
tag: child.tag
}))
})
// 移动到下一个分支
currentBranch = childIndex ? this.allBranches.find(b => b.index == childIndex): null;
}
// 更新当前分支
this.currentBranch = currentBranch;
},
采用requestAnimationFrame实现平滑轮询:
async startPolling(messageId) {
const POLLING_TIMEOUT = 20000; // 5秒超时
let pollingStartTime = Date.now();
const processBatch = async () => {
if (!this.isPolling) return;
try {
// 检查是否超时
if (Date.now() - pollingStartTime > POLLING_TIMEOUT) {
throw new Error('轮询超时,未收到有效响应');
}
const params = new URLSearchParams();
params.append('messageId', messageId); // 确保参数名完全匹配
params.append('batchSize', '5'); // 字符串形式
const response = await this.$axios.get('/api/chat/pollMessages', {
params: params,
paramsSerializer: params => params.toString() // 使用默认序列化
});
if (response.data && response.data.length) {
let shouldStop = false;
// 重置超时计时器(每次收到有效数据就重置)
pollingStartTime = Date.now();
let hasNewContent = false;
// 批量处理消息
for (const msg of response.data) {
// 检查是否收到停止信号
if (msg.finish === "stop") {
shouldStop = true;
}
// 更新AI消息内容
const aiMsg = this.messageListForShow.find(m => m.messageId === messageId);
if (aiMsg) {
aiMsg.content = aiMsg.content || { text: '' };
const oldLength = aiMsg.content.text.length;
await this.typeText(aiMsg, msg.text);
if (aiMsg.content.text.length > oldLength) {
hasNewContent = true; // 只有内容变化时才标记
}
}
}
// 只有内容变化时才强制更新和滚动
if (hasNewContent) {
this.$forceUpdate();
this.scrollToBottom();
}
// 如果收到停止信号,则中止轮询
if (shouldStop) {
this.stopPolling();
const aiMsg = this.messageListForShow.find(m => m.messageId === messageId);
if (aiMsg) {
// 将AI消息加入currentBranch
this.currentBranch.messageLocals.push({
messageId:aiMsg.messageId,
role: 'assistant',
branchId: this.currentBranch.branchId,
content: {
text: aiMsg.content.text,
files: []
},
timestamp: new Date()
});
this.modifiedBranch.push(this.currentBranch);
// 调用封装的方法保存currentBranch,并手动传入branchList
console.log(this.modifiedBranch)
await this.saveBranchList(this.modifiedBranch);
this.modifiedBranch = [];
await this.fetchData(this.chatRecordId)
this.scrollToBottom();
}
this.isLoading = false;
return;
}
}
// 继续轮询
this.pollingAnimationFrame = requestAnimationFrame(processBatch);
} catch (error) {
this.modifiedBranch = [];
this.stopPolling();
this.isLoading = false;
await this.loadChatMessages(this.chatRecordId)
await this.buildPathForTargetBranch(this.rootBranch);
this.$message.error('获取消息失败: ' + error.message);
this.scrollToBottom();
}
};
this.isPolling = true;
processBatch();
},
stopPolling() {
this.isPolling = false;
if (this.pollingTimer) {
clearTimeout(this.pollingTimer);
this.pollingTimer = null;
}
},
后端通过发送信息,再采用新建线程,异步地将接受的内容加入到轮询队列中,来实现前后端文字地流式输出
@PostMapping("/sendMessageWithPoll")
public GlobalResult<String> sendMessage(
@RequestParam(value = "chatRequest") String chatRequestStr,
@RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestParam(value = "fileMessageId", required = false) String fileMessageId
) throws JsonProcessingException {
// 手动解析JSON字符串
ObjectMapper objectMapper = new ObjectMapper();
ChatRequest chatRequest = objectMapper.readValue(chatRequestStr, ChatRequest.class);
List<MessageLocal> messageList = chatRequest.getMessageList();
List<MessageLocalDto> collect = messageList.stream().map((item) -> {
MessageLocalDto messageLocalDto = new MessageLocalDto(item);
if(fileMessageId != null && !fileMessageId.isEmpty()){
if (Objects.equals(messageLocalDto.getMessageId(), fileMessageId)) {
messageLocalDto.setUploadFiles(files);
}
}
return messageLocalDto;
}).collect(Collectors.toList());
List<MessageLocal> messageLocals = chatService.convertMessageListDto(collect);
Interviewer interviewer = chatRequest.getInterviewer();
// 参数验证
if (messageList == null || messageList.isEmpty() || messageList.get(messageList.size() - 1).getContent().getText().isEmpty()) {
throw new ServiceException("缺少发送信息");
}
if (interviewer == null) {
throw new ServiceException("未设置面试官");
}
Long idUser = UserUtils.getCurrentUserByToken().getIdUser();
// 异步处理消息
String messageId = String.valueOf(UUID.randomUUID());
try {
chatService.sendMessageToInterviewer(
messageLocals,
interviewer,
idUser,
messageId,
output -> {
// 将每个输出添加到队列
MessageQueueUtil.addMessage(output);
//System.out.println("add queue: "+ output.getText());
}
);
} catch (Exception e) {
e.printStackTrace();
MessageQueueUtil.addMessage(new ChatOutput("系统错误: " + e.getMessage()));
}
return GlobalResultGenerator.genSuccessStringDataResult(messageId);
}
@GetMapping("/pollMessages")
public GlobalResult<List<ChatOutput>> pollMessages(
@RequestParam("messageId") String messageId,
@RequestParam(defaultValue = "10") int batchSize) {
// MessageQueueUtil.check();
// System.out.println("messageId: "+ messageId);
// 参数校验
if (messageId == null || messageId.isEmpty()) {
return GlobalResultGenerator.genErrorResult("messageId不能为空");
}
if (batchSize <= 0 || batchSize > 100) {
batchSize = 10; // 设置合理的默认值
}
Long userId = UserUtils.getCurrentUserByToken().getIdUser();
long startTime = System.currentTimeMillis();
final long timeout = 5000; // 5秒超时
final long pollInterval = 200; // 轮询间隔200ms
List<ChatOutput> batch = new ArrayList<>(batchSize);
boolean hasStopSignal = false; // 新增状态位
try {
while ((System.currentTimeMillis() - startTime) < timeout) {
List<ChatOutput> messages = MessageQueueUtil.pollBatch(messageId, batchSize);
if (!messages.isEmpty()) {
//增加验证信息
ChatOutput chatOutput = messages.get(0);
if(!userId.equals(chatOutput.getUserId())){
throw new ServiceException("验证失败");
}
// 检查当前批次是否有stop信号
hasStopSignal = messages.stream()
.anyMatch(msg -> "stop".equals(msg.getFinish())) ;
batch.addAll(messages);
if (hasStopSignal) {
MessageQueueUtil.removeQueue(messageId);
break;
}
// 如果有消息立即返回,不等待超时
if (!batch.isEmpty()) {
break;
}
}
Thread.sleep(pollInterval);
}
return GlobalResultGenerator.genSuccessResult(batch);
} catch (Exception e) {
e.printStackTrace();
return GlobalResultGenerator.genErrorResult("获取消息失败");
}
}
async typeText(messageObj, newText) {
const typingSpeed = 30; // 控制打字速度(ms/字)
for (let i = 0; i < newText.length; i++) {
await new Promise(resolve => setTimeout(resolve, typingSpeed));
messageObj.content.text += newText[i];
this.$forceUpdate();
this.scrollToBottom();
}
}
采用FormData实现多文件上传:
const formData = new FormData();
formData.append('chatRequest', JSON.stringify({
messageList: this.chatMessages,
interviewer: this.currentInterviewer
}));
this.processedFiles.forEach(file => {
formData.append('files', file.raw);
});
public List<MessageLocal> convertMessageListDto(List<MessageLocalDto> messageLocalDtoList){
List<MessageLocal> messageLocalList = messageLocalDtoList.stream().map(item -> {
//对有上传文件的处理
//当uploadFiles有值时,说明这个信息是第一次发送,尚未执行转化,或者是重新上传文件,覆盖原来的文件
//提取文件中的内容分析,获取fileInfoList后保存这条信息
List<MultipartFile> uploadFiles = item.getUploadFiles();
MessageLocal messageLocal;
if (uploadFiles != null && !uploadFiles.isEmpty()) {
List<FileInfo> files = item.getContent().getFiles();
for (MultipartFile file : uploadFiles) {
try {
MessageFileDto messageFileDto = convertMessageFile(file);
FileInfo fileInfo = messageFileDto.getFileInfo();
fileInfo.setTextContent(messageFileDto.getText());
files.add(fileInfo);
} catch (IOException e) {
e.printStackTrace();
throw new ServiceException("文件分析失败");
}
}
//构建新的messageLocal并保存
Content content = item.getContent();
content.setFiles(files);
item.setContent(content);
messageLocal = item;
updateMessageLocalWithRepository(messageLocal);
}
messageLocal = item;
return messageLocal;
}).collect(Collectors.toList());
return messageLocalList;
}
使用markdown-it库实现AI回答的美化展示:
renderMarkdown(text, role) {
if (role === 'assistant') {
const rendered = this.md.render(text || '');
return `${rendered}`;
}
return text;
}
分支状态同步问题
长消息接收卡顿
文件上传上下文关联
本周实现的分支式对话系统为AI面试场景提供了更自然的交互方式,关键技术点包括:
这些技术的组合应用,使得我们的AI面试系统在交互流畅性和场景适应性上达到了较好水平。