詹学伟
詹学伟
Published on 2025-03-04 / 34 Visits
0
0

Deepseek官方API对接 & Trae超酷体验

说明:本章节将讲解使用java对接deepseek的官方api,同时使用强大的编辑器Trae完成有趣的对话框。前面章节中也讲到了对接deepseek的api,但那是基于ollama的,严格讲那并不是真的对接deepseek。

deepseek的api返回的结果和平时咱们开发中restful不太一样,对话接口返回的一种chunk消息块的结果(流式),我理解就像一个长连接不断的输出,也有待呢类似websocket。好了下面开始拿出干货!~~

一、找到官方文档

说明:请同学们提前注册账号,并充值money,然后创建api key并保存好~~

deepseek开放平台:

https://platform.deepseek.com

点开开放平台右侧的菜单:接口文档

然后紧跟右侧菜单,快速开始->首次调用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也是好几次才满足我的期望,可能是我的提示词不够精准吧~~~

有兴趣的同学们赶紧整一个玩玩,听有意思的~~~


Comment