说明:本章节将讲解使用java对接deepseek的官方api,同时使用强大的编辑器Trae完成有趣的对话框。前面章节中也讲到了对接deepseek的api,但那是基于ollama的,严格讲那并不是真的对接deepseek。
deepseek的api返回的结果和平时咱们开发中restful不太一样,对话接口返回的一种chunk消息块的结果(流式),我理解就像一个长连接不断的输出,也有待呢类似websocket。好了下面开始拿出干货!~~
一、找到官方文档
说明:请同学们提前注册账号,并充值money,然后创建api key并保存好~~
deepseek开放平台:
点开开放平台右侧的菜单:接口文档
然后紧跟右侧菜单,快速开始->首次调用api:
这就是一个很好的对话接口。
二、后端接口,直接贴代码
package com.minth.dingtalk.controller;
import com.alibaba.fastjson.JSON;
import com.minth.dingtalk.entity.test.ChatRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController {
@Value("${deepseek.api.url}")
private String API_URL;
@Value("${deepseek.api.key}")
private String API_KEY;
@Autowired
@Qualifier("webClient")
private WebClient webClient;
private static final String KEY_CHOICES = "choices";
private static final String KEY_DELTA = "delta";
@PostMapping("/chat")
public SseEmitter chat(@RequestBody ChatRequest request) {
SseEmitter emitter = new SseEmitter();
webClient.post()
.uri(API_URL)
.header("Authorization", "Bearer " + API_KEY)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(String.class)
.subscribe(
data -> {
try {
if (data != null && data.length() > 20) {
Map<String, Object> map = JSON.parseObject(data, Map.class);
Object choices = map.get(KEY_CHOICES);
if (choices instanceof List) {
List<?> list = (List<?>) choices;
if (!list.isEmpty()) {
Object object1 = list.get(0);
if (object1 instanceof Map) {
Map<String, Object> map1 = (Map<String, Object>) object1;
Object delta = map1.get(KEY_DELTA);
if (delta instanceof Map) {
Map<String, Object> map2 = (Map<String, Object>) delta;
Object content = map2.get("content");
if (content != null && StringUtils.isNoneBlank(content.toString())) {
log.info(content.toString());
emitter.send(content.toString());
}
}
}
}
}
} else {
log.warn("Data is too short or null: {}", data);
}
} catch (Exception e) {
log.error("Error processing data: {}, error: {}", data, e.getMessage(), e);
}
},
emitter::completeWithError,
emitter::complete
);
return emitter;
}
}
三、前端代码
<template>
<div class="chat-container">
<!-- 消息展示区域 -->
<div class="message-area" ref="messageBox">
<div v-for="(msg, index) in messages" :key="index" class="message-wrapper" :class="msg.role === 'user' ? 'user-wrapper' : 'assistant-wrapper'">
<!-- 头像 -->
<img :src="msg.role === 'user' ? userIcon : assistantIcon" alt="Avatar" class="avatar">
<!-- 消息内容 -->
<div :class="['message-item', msg.role === 'user' ? 'user-message-item' : 'assistant-message-item']">
<div v-html="msg.role === 'user' ? msg.content : msg.visibleContent"></div>
</div>
</div>
<div v-if="loading" class="typing-indicator">
<span>正在输入中...</span>
<div class="typing-dots">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
</div>
<!-- 输入控制区域 -->
<div class="input-area" style="justify-content: flex-end;">
<input
v-model="inputText"
placeholder="请输入问题"
@keyup.enter="startStream"
:disabled="loading"
/>
<button @click="startStream" :disabled="loading || !inputText.trim()">发送</button>
<button @click="cancelStream" v-if="loading">停止</button>
</div>
</div>
</template>
<script>
import { marked } from 'marked';
import DOMPurify from 'dompurify';
export default {
data() {
return {
messages: [],
inputText: '',
loading: false,
controller: null,
buffer: '',
userIcon: 'https://img1.baidu.com/it/u=728383910,3448060628&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=800', // 替换为实际的用户图标链接
assistantIcon: 'https://img2.baidu.com/it/u=3125720667,3310351231&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', // 替换为实际的助手图标链接
userName: '你',
assistantName: '助手'
};
},
methods: {
async startStream() {
if (!this.inputText.trim()) {
this.$notify.warning({
title: '提示',
message: '输入内容不能为空',
duration: 2000
});
return;
}
try {
this.loading = true;
this.controller = new AbortController();
// 添加用户消息到消息列表
this.messages.push({ role: 'user', content: `${this.inputText.trim()}` });
this.$nextTick(this.scrollToBottom);
var requestBody = {
"model":"deepseek-chat",
"messages":this.messages,
"stream":true
}
const response = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: this.controller.signal
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) {
this.finalizeMessage();
break;
}
this.buffer += decoder.decode(value, { stream: true });
this.processSSEData();
}
} catch (error) {
this.handleError(error);
} finally {
// 清空输入框
this.inputText = '';
}
},
processSSEData() {
const events = this.buffer.split('\n\n');
events.slice(0, -1).forEach(event => {
const dataLine = event.replace('data:', '').trim();
if (dataLine) {
// 配置marked不使用p标签
const sanitized = DOMPurify.sanitize(marked.parseInline(dataLine));
// 确保内容不为空
if (sanitized && sanitized.trim() !== '') {
this.appendContent(sanitized);
}
}
});
this.buffer = events[events.length - 1];
},
appendContent(content) {
if (!this.messages.length || this.messages[this.messages.length - 1].role === 'user') {
this.messages.push({
role: 'assistant',
content: content,
visibleContent: '', // 初始时为空
complete: false
});
this.startTypingEffect(this.messages[this.messages.length - 1]);
} else {
const lastMessage = this.messages[this.messages.length - 1];
lastMessage.content += content;
this.startTypingEffect(lastMessage);
}
this.$nextTick(this.scrollToBottom);
},
startTypingEffect(message) {
if (message.typingEffect) return; // 防止重复启动
message.typingEffect = true;
// 清空visibleContent以确保从左到右显示
message.visibleContent = '';
let chunkSize = 3;
let currentIndex = 0;
const showChunk = () => {
if (currentIndex < message.content.length) {
// 从左到右逐个字符显示
message.visibleContent = message.content.slice(0, currentIndex + chunkSize);
currentIndex += chunkSize;
this.$nextTick(this.scrollToBottom);
setTimeout(showChunk, 50);
} else {
message.typingEffect = false;
}
};
showChunk();
},
finalizeMessage() {
if (this.messages.length && this.messages[this.messages.length - 1].role === 'assistant') {
this.messages[this.messages.length - 1].complete = true;
}
this.loading = false;
},
cancelStream() {
if (this.controller) this.controller.abort();
this.loading = false;
},
scrollToBottom() {
const container = this.$refs.messageBox;
container.scrollTop = container.scrollHeight;
},
handleError(error) {
if (error.name === 'AbortError') return;
console.error('SSE 连接异常:', error);
this.$notify.error({
title: '服务响应异常',
message: '请稍后重试或联系管理员。',
duration: 5000
});
this.loading = false;
}
}
};
</script>
<style scoped>
/* 整体聊天容器样式 */
.chat-container {
max-width: 800px;
margin: 20px auto;
border-radius: 12px;
background-color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
height: 90vh;
}
/* 消息展示区域样式 */
.message-area {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
}
/* 输入控制区域样式 */
.input-area {
padding: 20px;
background-color: #fff;
display: flex;
gap: 15px;
align-items: center;
}
/* 输入框样式 */
.input-area input {
flex: 1;
padding: 14px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s ease;
}
.input-area input:focus {
border-color: #007bff;
outline: none;
}
/* 按钮样式 */
.input-area button {
padding: 14px 24px;
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.input-area button:hover {
background-color: #0056b3;
}
.input-area button:disabled {
background-color: #b3d7ff;
cursor: not-allowed;
}
/* 用户消息和助手消息的公共样式 */
.message-item {
margin-bottom: 8px; /* 减少消息间距 */
padding: 8px 12px; /* 调整内边距 */
border-radius: 5px;
max-width: 80%;
word-wrap: break-word;
display: flex;
flex-direction: column;
font-size: 14px; /* 微信字体大小 */
line-height: 1.5; /* 调整行高 */
}
/* 消息容器样式 */
.message-wrapper {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
}
/* 用户消息容器样式 */
.user-wrapper {
flex-direction: row-reverse;
}
/* 助手消息容器样式 */
.assistant-wrapper {
flex-direction: row;
}
/* 头像样式 */
.avatar {
width: 40px;
height: 40px;
border-radius: 5px;
margin: 0 8px;
}
/* 用户消息样式 */
.user-message-item {
background-color: #95ec69;
color: #000;
border-radius: 8px 0 8px 8px;
}
/* 助手消息样式 */
.assistant-message-item {
background-color: #fff;
color: #000;
border-radius: 0 8px 8px 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* 头像样式 */
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
margin-right: 8px;
}
/* 用户头像样式,调整右边距 */
.user-message-item .avatar {
margin-left: 8px;
margin-right: 0;
}
/* 名称样式 */
.name {
font-weight: bold;
}
/* 输入控制区域样式 */
.input-area {
padding: 20px;
background-color: #fff;
display: flex;
gap: 15px;
border-top: 1px solid #e0e0e0;
}
/* 输入框样式 */
.input-area input {
flex: 1;
padding: 12px;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 16px;
}
/* 按钮样式 */
.input-area button {
padding: 12px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s ease;
}
/* 按钮悬停效果 */
.input-area button:hover {
background-color: #0056b3;
}
/* 加载指示器样式 */
.typing-indicator {
color: #999;
padding: 10px;
font-style: italic;
display: flex;
gap: 3px;
}
/* 加载点动画样式 */
.typing-dot {
animation: typing 1s infinite;
}
@keyframes typing {
0%, 100% { opacity: 0.2; }
50% { opacity: 1; }
}
</style>
说明:该代码除了架子,其余均ai生成。
三、Trae
不得不说Trae写代码是真的放便,真的可以让一个不懂代码的人完成不错的作品。尤其是前端代码,真的太优秀了,感觉前端的同学压力倍增~~~
四、效果展示
略。。。。。。。。。
说明:就这个类似打字机的效果,ai也是好几次才满足我的期望,可能是我的提示词不够精准吧~~~
有兴趣的同学们赶紧整一个玩玩,听有意思的~~~