面试对话标题自动总结
主要实现思路 :每当AI回复用户之后,调用方法查看当前对话是否大于三条,如果大于则将用户的两条和AI回复的一条对话传给DeepSeek让其进行总结(后端),总结后调用updateChatTopic
进行更新标题,此外本次标题的更改还实现了仿打字机效果。
后端实现
首先,要在.env
文件中配置DEEPSEEK_API
,然后在application.yml
中添加:
yml
deepseek:
api:
key: ${DEEPSEEK_API} # DeepSeek API Key,从环境变量中获取
之后创建DeesSeekService.java
java
package com.sdumagicode.backend.openai.service;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import okhttp3.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* DeepSeek API服务类
* 用于与DeepSeek API进行交互
*/
@Service
public class DeepSeekService {
private static final Logger logger = LoggerFactory.getLogger(DeepSeekService.class);
private static final String API_URL = "https://api.deepseek.com/v1/chat/completions";
@Value("${deepseek.api.key}")
private String apiKey;
private final OkHttpClient client;
private final ObjectMapper objectMapper;
public DeepSeekService() {
this.client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
this.objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
}
/**
* 发送聊天请求到DeepSeek API
*
* @param messages 消息列表
* @param model 模型名称,默认为 "deepseek-chat"
* @param temperature 温度参数,控制随机性
* @return API响应的JSON字符串
* @throws IOException 如果API请求失败
*/
public String chatCompletion(List<Map<String, String>> messages, String model, double temperature) throws IOException {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("messages", messages);
requestBody.put("model", model);
requestBody.put("temperature", temperature);
String jsonBody = objectMapper.writeValueAsString(requestBody);
RequestBody body = RequestBody.create(
MediaType.parse("application/json"), jsonBody);
Request request = new Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.addHeader("Content-Type", "application/json")
.post(body)
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("API请求失败: " + response.code() + " " + response.message());
}
return response.body().string();
}
}
/**
* 发送聊天请求到DeepSeek API(使用默认参数)
*
* @param messages 消息列表
* @return API响应的JSON字符串
* @throws IOException 如果API请求失败
*/
public String chatCompletion(List<Map<String, String>> messages) throws IOException {
return chatCompletion(messages, "deepseek-chat", 0.7);
}
/**
* 创建聊天消息
*
* @param role 角色 (system, user, assistant)
* @param content 消息内容
* @return 包含角色和内容的Map
*/
public Map<String, String> createMessage(String role, String content) {
Map<String, String> message = new HashMap<>();
message.put("role", role);
message.put("content", content);
return message;
}
/**
* 流式聊天请求(SSE)
*
* @param messages 消息列表
* @param model 模型名称
* @param temperature 温度参数
* @param callback 回调函数,用于处理每个SSE事件
* @throws IOException 如果API请求失败
*/
public void streamingChatCompletion(List<Map<String, String>> messages, String model, double temperature, Callback callback) throws IOException {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("messages", messages);
requestBody.put("model", model);
requestBody.put("temperature", temperature);
requestBody.put("stream", true);
String jsonBody = objectMapper.writeValueAsString(requestBody);
RequestBody body = RequestBody.create(
MediaType.parse("application/json"), jsonBody);
Request request = new Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.addHeader("Content-Type", "application/json")
.post(body)
.build();
client.newCall(request).enqueue(callback);
}
}
该文件用于与DeepSeek AI API进行交互。主要功能包括:
-
基本配置:
- 使用OkHttpClient进行HTTP请求
- 配置ObjectMapper处理JSON序列化/反序列化
- 从配置文件读取API密钥
-
主要方法:
chatCompletion
:向DeepSeek API发送聊天请求,有两个版本:- 完整参数版:可指定消息、模型和温度
- 简化版:使用默认参数
createMessage
:创建聊天消息对象streamingChatCompletion
:流式聊天请求,支持SSE(服务器发送事件)listModels
:获取可用模型列表
-
技术特点:
- 使用Spring的
@Service
注解标记为服务 - 通过
@Value
注入API密钥 - 支持异步回调处理流式响应
- 包含完整的异常处理和日志记录
- 使用Spring的
这个服务类是后端与DeepSeek AI API通信的核心组件,负责处理所有AI聊天相关的请求。
DeepSeekController.java
java
package com.sdumagicode.backend.openai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sdumagicode.backend.core.result.GlobalResult;
import com.sdumagicode.backend.core.result.GlobalResultGenerator;
import com.sdumagicode.backend.openai.service.DeepSeekService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* DeepSeek API控制器
* 提供DeepSeek模型API的接口
*/
@RestController
@RequestMapping("/api/v1/deepseek")
public class DeepSeekController {
@Autowired
private DeepSeekService deepSeekService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 发送聊天请求
*
* @param requestBody 请求体,包含messages, model, temperature
* @return 聊天响应
*/
@PostMapping("/chat")
public GlobalResult<String> chatCompletion(@RequestBody Map<String, Object> requestBody) {
try {
@SuppressWarnings("unchecked")
List<Map<String, String>> messages = (List<Map<String, String>>) requestBody.get("messages");
String model = requestBody.containsKey("model") ? (String) requestBody.get("model") : "deepseek-chat";
double temperature = requestBody.containsKey("temperature") ?
Double.parseDouble(requestBody.get("temperature").toString()) : 0.7;
String response = deepSeekService.chatCompletion(messages, model, temperature);
return GlobalResultGenerator.genSuccessResult(response);
} catch (Exception e) {
return GlobalResultGenerator.genErrorResult("聊天请求失败:" + e.getMessage());
}
}
/**
* 生成对话摘要
*
* @param requestBody 包含对话消息和chatId的请求体
* @return 生成的摘要
*/
@PostMapping("/summarize")
public GlobalResult<String> summarizeConversation(@RequestBody Map<String, Object> requestBody) {
try {
@SuppressWarnings("unchecked")
List<Map<String, Object>> messages = (List<Map<String, Object>>) requestBody.get("messages");
// 准备系统提示词和用户消息
List<Map<String, String>> promptMessages = new ArrayList<>();
// 添加系统提示
Map<String, String> systemPrompt = new HashMap<>();
systemPrompt.put("role", "system");
systemPrompt.put("content", "你是一个专业的面试对话摘要生成器。请根据以下面试对话生成一个简短的标题,标题应概括对话的主要内容。"
+ "标题必须精炼,不超过20个字,不要加任何前缀和标点符号,直接输出标题文本。标题应突出面试的主要话题或技能领域。");
promptMessages.add(systemPrompt);
// 构建对话历史
StringBuilder conversationBuilder = new StringBuilder();
for (Map<String, Object> message : messages) {
String role = (String) message.get("role");
String content = (String) message.get("content");
if (content == null || content.trim().isEmpty()) {
continue;
}
conversationBuilder.append(role.equals("assistant") ? "面试官: " : "候选人: ");
conversationBuilder.append(content).append("\n\n");
}
// 添加用户消息,包含对话内容
Map<String, String> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", "以下是一段面试对话,请为其生成一个简短的标题:\n\n" + conversationBuilder.toString());
promptMessages.add(userMessage);
// 调用DeepSeek API生成摘要,使用较低的温度以获得更确定性的结果
String response = deepSeekService.chatCompletion(promptMessages, "deepseek-chat", 0.3);
// 解析响应,提取摘要文本
JsonNode responseJson = objectMapper.readTree(response);
String summary = responseJson.path("choices").get(0).path("message").path("content").asText();
// 清理摘要文本,去除多余的引号、空格和换行符
summary = summary.replaceAll("\"", "").trim();
summary = summary.replaceAll("\\r?\\n", " ").trim();
return GlobalResultGenerator.genSuccessResult(summary);
} catch (Exception e) {
return GlobalResultGenerator.genErrorResult("生成摘要失败:" + e.getMessage());
}
}
前端实现
自动总结标题功能在handlePollingCompleted
方法中实现,主要流程如下:
javascript
async handlePollingCompleted() {
try {
if (!this.activeChatRecord) return;
// 检查是否有足够内容生成摘要且未生成过
if (
this.$refs.chatArea &&
this.$refs.chatArea.messageListForShow &&
!this.summarizedChatIds.has(this.activeChatRecord)
) {
const messages = this.$refs.chatArea.messageListForShow;
// 至少需要3条消息才生成摘要
if (messages.length >= 3) {
// 准备对话数据
const dialogData = messages.map(msg => ({
role: msg.role,
content: msg.content.text
}));
try {
// 调用DeepSeek API生成摘要
const summaryResponse = await axios.post('/api/deepseek/summarize', {
messages: dialogData,
chatId: this.activeChatRecord
});
if (summaryResponse.message) {
const summary = summaryResponse.message;
// 更新对话标题
await axios.post('/api/chat/updateChatTopic', null, {
params: {
chatId: this.activeChatRecord,
newTopic: summary
}
});
// 记录已生成摘要的对话ID
this.summarizedChatIds.add(this.activeChatRecord);
// 重新加载聊天记录显示新标题
await this.loadChatRecords();
// 启动打字机效果展示新标题
this.startTypingAnimation(this.activeChatRecord, summary);
}
} catch (error) {
console.warn('生成对话摘要失败:', error);
// 即使失败也标记为已处理,避免重复尝试
this.summarizedChatIds.add(this.activeChatRecord);
}
}
}
// 后续处理actions的代码...
} catch (error) {
console.error('获取actions失败:', error);
}
}
关键设计要点:
- 触发时机 :当轮询完成后自动触发,通过
handlePollingCompleted
方法 - 条件判断 :
- 必须有活跃的聊天记录
- 对话消息数量至少3条
- 该对话ID未被记录在
summarizedChatIds
集合中
- 数据收集 :从
messageListForShow
中提取对话内容并格式化 - API调用 :通过
/api/deepseek/summarize
请求生成摘要 - 标题更新 :通过
/api/chat/updateChatTopic
更新对话标题 - 状态管理 :
- 使用
summarizedChatIds
集合跟踪已处理的对话 - 无论成功失败都标记为已处理,避免重复请求
- 使用
- 用户体验:使用打字机效果动态展示新标题
打字机效果
打字机效果在文件中的实现主要包含三个核心方法和相应的CSS样式,用于在聊天记录标题中实现逐字显示的动画效果。
数据结构
javascript
typingAnimation: {
chatId: null, // 当前执行动画的对话ID
originalText: '', // 完整文本
displayText: '', // 当前显示的文本(逐渐增加)
isActive: false, // 动画激活状态
charIndex: 0, // 当前字符索引
timerId: null // 定时器ID
}
核心方法
- 启动动画:
javascript
startTypingAnimation(chatId, text) {
this.stopTypingAnimation(); // 先停止已有动画
if (!text || !chatId || text.length < 3) return;
this.typingAnimation = {
chatId, originalText: text, displayText: '',
isActive: true, charIndex: 0, timerId: null
};
setTimeout(() => this.animateNextChar(), 200); // 延迟启动
}
- 字符添加动画:
javascript
animateNextChar() {
const { charIndex, originalText } = this.typingAnimation;
if (charIndex <= originalText.length) {
this.typingAnimation.displayText = originalText.substring(0, charIndex);
this.typingAnimation.charIndex = charIndex + 1;
// 随机速度模拟自然打字
const baseSpeed = 70;
const randomVariation = Math.random() * 100;
const speed = baseSpeed + randomVariation;
this.typingAnimation.timerId = setTimeout(() => {
this.animateNextChar();
}, speed);
} else {
this.stopTypingAnimation(true);
}
}
- 停止动画:
javascript
stopTypingAnimation(completed = false) {
if (this.typingAnimation.timerId) {
clearTimeout(this.typingAnimation.timerId);
}
if (completed) {
// 完成后短暂延迟
setTimeout(() => {
this.typingAnimation.isActive = false;
}, 500);
} else {
// 立即重置
this.typingAnimation = {
chatId: null, originalText: '', displayText: '',
isActive: false, charIndex: 0, timerId: null
};
}
}
视觉效果
- 闪烁光标: 使用CSS动画模拟打字光标闪烁
css
.cursor {
width: 2px;
height: 16px;
background-color: #409eff;
animation: blink 0.7s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
- 调用时机:
- 生成聊天摘要后:
this.startTypingAnimation(this.activeChatRecord, summary);
- 重命名对话后:
this.startTypingAnimation(chatId, this.newTopicName);