⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7304265842954469413 「summo」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

一、前言

最近ChatGPT非常受欢迎,尤其是在编写代码方面,我每天都在使用。随着使用时间的增长,我开始对其原理产生了一些兴趣。虽然我无法完全理解这些AI大型模型的算法和模型,但我认为可以研究一下其中的交互逻辑。特别是,我想了解它是如何实现在发送一个问题后不需要等待答案完全生成,而是通过不断追加的方式实现实时回复的。

F12打开控制台后,我发现在点击发送后,它会发送一个普通的请求。但是回复的方式却不同,它的类型是eventsource。一次请求会不断地获取数据,然后前端的聊天组件会动态地显示回复内容,回复的内容是用Markdown格式来展示的。

在了解了前面的这些东西后我就萌生了自己写一个小demo的想法。起初,我打算使用openai的接口,并写一个小型的UI组件。然而,由于openai账号申请复杂且存在网络问题,很多人估计搞不定,所以我最终选择了通义千问。通义千问有两个优点:一是它是国内的且目前调用是免费的,二是它提供了Java-SDK和API文档,开发起来容易。

作为后端开发人员,按照API文档调用模型并不难,但真正难到我的是前端UI组件的编写。我原以为市面上会有很多支持EventStream的现成组件,但事实上并没有。不知道是因为这个功能太容易还是太难,总之,对接通义千问只花了不到一小时,而编写一个UI对话组件却花了整整两天的时间!接下来,我将分享一些我之前的经验,希望可以帮助大家少走坑。

首先展示一下我的成品效果

二、通义千问开发Key申请

1. 登录阿里云,搜索通义千问

2. 点击”开通DashScope”

3. 创建一个API-KEY

4. 对接流程

1)API文档地址

https://help.aliyun.com/zh/dashscope/developer-reference/api-details

2)Java-SDK依赖

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.8.2</version>
</dependency>

三、支持EventStream格式的接口

1. 什么是EventStream

EventStream是一种流式数据格式,用于实时传输事件数据。它是基于HTTP协议的,但与传统的请求-响应模型不同,它是一个持续的、单向的数据流。它可用于推送实时数据、日志、通知等,所以EventStream很适合这种对话式的场景。在Spring Boot中,主要有以下框架和模块支持EventStream格式:

  • Spring WebFlux:Spring WebFlux是Spring框架的一部分,用于构建反应式Web应用程序。
  • Reactor:Reactor是一个基于响应式流标准的库,是Spring WebFlux的核心组件。
  • Spring Cloud Stream:Spring Cloud Stream是一个用于构建消息驱动的微服务应用的框架。

这次我使用的是reactor-core框架。

2. 写一个例子

maven依赖

<!-- Reactor Core -->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.4.6</version>
</dependency>

代码如下

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

import java.time.Duration;
import java.time.LocalTime;

@RestController
@RequestMapping("/event-stream")
public class EventStreamController {

@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> getEventStream() {
return Flux.interval(Duration.ofSeconds(1))
.map(sequence -> "Event " + sequence + " at " + LocalTime.now());
}
}

调用一下接口后就可以看到浏览器上在不断地打印时间戳了

四、项目实现

这个就不BB了,直接贴代码!

1. 项目结构

2. pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.17</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.chatrobot</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- 通义千问SDK -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.8.2</version>
</dependency>

<!-- Reactor Core -->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.4.6</version>
</dependency>

<!-- Web组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<artifactId>logback-classic</artifactId>
<groupId>ch.qos.logback</groupId>
</exclusion>
</exclusions>
</dependency>

</dependencies>

</project>

3. 代码

1)后端代码

  • DemoApplication.java
package com.chatrobot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}

}
  • EventController.java
package com.chatrobot.controller;

import java.time.Duration;
import java.time.LocalTime;
import java.util.Arrays;

import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.aigc.generation.models.QwenParam;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;

import io.reactivex.Flowable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/events")
@CrossOrigin
public class EventController {

@Value("${api.key}")
private String apiKey;

@GetMapping(value = "/streamAsk", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamAsk(String q) throws Exception {

Generation gen = new Generation();

// 创建用户消息对象
Message userMsg = Message
.builder()
.role(Role.USER.getValue())
.content(q)
.build();

// 创建QwenParam对象,设置参数
QwenParam param = QwenParam.builder()
.model(Generation.Models.QWEN_PLUS)
.messages(Arrays.asList(userMsg))
.resultFormat(QwenParam.ResultFormat.MESSAGE)
.topP(0.8)
.enableSearch(true)
.apiKey(apiKey)
// get streaming output incrementally
.incrementalOutput(true)
.build();

// 调用生成接口,获取Flowable对象
Flowable<GenerationResult> result = gen.streamCall(param);

// 将Flowable转换成Flux<ServerSentEvent<String>>并进行处理
return Flux.from(result)
// add delay between each event
.delayElements(Duration.ofMillis(1000))
.map(message -> {
String output = message.getOutput().getChoices().get(0).getMessage().getContent();
System.out.println(output); // print the output
return ServerSentEvent.<String>builder()
.data(output)
.build();
})
.concatWith(Flux.just(ServerSentEvent.<String>builder().comment("").build()))
.doOnError(e -> {
if (e instanceof NoApiKeyException) {
// 处理 NoApiKeyException
} else if (e instanceof InputRequiredException) {
// 处理 InputRequiredException
} else if (e instanceof ApiException) {
// 处理其他 ApiException
} else {
// 处理其他异常
}
});
}

@GetMapping(value = "test", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> testEventStream() {
return Flux.interval(Duration.ofSeconds(1))
.map(sequence -> "Event " + sequence + " at " + LocalTime.now());
}
}

2)前端代码

  • chat.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatBot</title>
<style>
body {
background: #f9f9f9;
/* 替换为您想要的背景颜色或图片 */
}

.chat-bot {
display: flex;
flex-direction: column;
width: 100%;
max-width: 800px;
margin: 50px auto;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
font-family: "Roboto", sans-serif;
background: #f5f5f5;
}

.chat-bot-header {
background: linear-gradient(to right, #1791ee, #9fdbf1);
color: white;
text-align: center;
padding: 15px;
font-size: 24px;
font-weight: 500;
}

.chat-bot-messages {
flex: 1;
padding: 20px;
min-height: 400px;
overflow-y: auto;
}

.userName {
margin: 0 10px;
}

.message-wrapper {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
border-radius: 20px;
}

.message-wrapper.user {
justify-content: flex-end;
border-radius: 20px;
}

.message-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #ccc;
margin-right: 10px;
margin-bottom: 10px;
/* 添加这一行 */
order: -1;
/* 添加这一行 */
text-align: right;
}

.message-avatar.user {
background-color: transparent;
display: flex;
justify-content: flex-end;
width: 100%;
margin-right: 0;
align-items: center;
}

.message-avatar.bot {
background-color: transparent;
display: flex;
justify-content: flex-start;
width: 100%;
margin-right: 0;
align-items: center;
}

.message-avatar-inner.user {
background-image: url("./luge.jpeg");
background-size: cover;
background-position: center;
width: 30px;
height: 30px;
border-radius: 50%;
}

.message-avatar-inner.bot {
background-image: url("./logo.svg");
background-size: cover;
background-position: center;
width: 30px;
height: 30px;
border-radius: 50%;
}

.message {
padding: 10px 20px;
border-radius: 15px;
font-size: 16px;
background-color: #d9edf7;
order: 1;
/* 添加这一行 */
}

.bot {
background-color: #e9eff5;
/* 添加这一行 */
}

.user {
background-color: #d9edf7;
color: #111111;
order: 1;
/* 添加这一行 */
}

.chat-bot-input {
display: flex;
align-items: center;
border-top: 1px solid #ccc;
padding: 10px;
background-color: #fff;
}

.chat-bot-input input {
flex: 1;
padding: 10px 15px;
border: none;
font-size: 16px;
outline: none;
}

.chat-bot-input button {
padding: 10px 20px;
background-color: #007bff;
border: none;
border-radius: 50px;
color: white;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
}

.chat-bot-input button:hover {
background-color: #0056b3;
}

@media (max-width: 768px) {
.chat-bot {
margin: 20px;
}

.chat-bot-header {
font-size: 20px;
}

.message {
font-size: 14px;
}
}

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

.loading-spinner {
width: 15px;
height: 15px;
border-radius: 50%;
border: 2px solid #d9edf7;
border-top-color: transparent;
animation: spin 1s infinite linear;
}
</style>
</head>
<body>
<div class="chat-bot">
<div class="chat-bot-header">
<img src="./logo.svg" alt="Logo" class="logo" />
通义千问
</div>
<div class="chat-bot-messages"></div>
<div class="chat-bot-input">
<input type="text" placeholder="输入你想问的问题" />
<button id="sendButton">Send</button>
</div>
</div>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/13.0.2/markdown-it.min.js"
integrity="sha512-ohlWmsCxOu0bph1om5eDL0jm/83eH09fvqLDhiEdiqfDeJbEvz4FSbeY0gLJSVJwQAp0laRhTXbUQG+ZUuifUQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script>
const userName = "summo";

document.addEventListener("DOMContentLoaded", function () {
const input = document.querySelector(".chat-bot-input input");
const messagesContainer = document.querySelector(".chat-bot-messages");
const sendButton = document.getElementById("sendButton");

function appendToMessage(messageTxt, sender, md, message) {
let messageElement = messagesContainer.querySelector(
`.message-wrapper.${sender}:last-child .message`
);

if (!messageElement) {
if (sender === "bot") {
messageElement = document.createElement("div");
messageElement.classList.add("message-avatar", sender);
messageElement.innerHTML = `<div class="message-avatar-inner ${sender}"></div><div class="userName">通义千问</div>`;
messagesContainer.appendChild(messageElement);
} else {
messageElement = document.createElement("div");
messageElement.classList.add("message-avatar", sender);
messageElement.innerHTML = `<div class="message-avatar-inner ${sender}"></div><div class="userName"">${userName}</div>`;
messagesContainer.appendChild(messageElement);
}
messageElement = document.createElement("div");
messageElement.classList.add("message-wrapper", sender);
messageElement.innerHTML = `<div class="message ${sender}"></div>`;
messagesContainer.appendChild(messageElement);

messageElement = messageElement.querySelector(".message");
}
// messageElement.textContent += messageTxt; // 追加文本
// messagesContainer.scrollTop = messagesContainer.scrollHeight; // 滚动到底部
let result = (message += messageTxt);
const html = md.renderInline(messageTxt);
messageElement.innerHTML += html;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}

function handleSend() {
const inputValue = input.value.trim();
if (inputValue) {
input.disabled = true;
sendButton.disabled = true;
sendButton.innerHTML = '<div class="loading-spinner"></div>';
const md = new markdownit();
// 修改按钮文本内容为"Loading..."
let message = "";
appendToMessage(inputValue, "user", md, message);
input.value = "";
const eventSource = new EventSource(
`http://localhost:8080/events/streamAsk?q=${encodeURIComponent(
inputValue
)}`
);
eventSource.onmessage = function (event) {
console.log(event.data);
appendToMessage(event.data, "bot", md, message);
};
eventSource.onerror = function () {
eventSource.close();
input.disabled = false;
sendButton.disabled = false;
sendButton.innerHTML = "Send";
};
}
}

document
.querySelector(".chat-bot-input button")
.addEventListener("click", handleSend);
input.addEventListener("input", function () {
sendButton.disabled = input.value.trim() === "";
});

input.addEventListener("keypress", function (event) {
if (event.key === "Enter" && !sendButton.disabled) {
handleSend();
}
});
});
</script>
</body>
</html>

另外还有两个头像,大家可以替换成自己喜欢的,好了文章到这里也就结束了,再秀一下我的成品👉

文章目录
  1. 1. 一、前言
  2. 2. 二、通义千问开发Key申请
    1. 2.0.1. 1. 登录阿里云,搜索通义千问
    2. 2.0.2. 2. 点击”开通DashScope”
    3. 2.0.3. 3. 创建一个API-KEY
    4. 2.0.4. 4. 对接流程
  • 3. 三、支持EventStream格式的接口
    1. 3.0.1. 1. 什么是EventStream
    2. 3.0.2. 2. 写一个例子
  • 4. 四、项目实现
    1. 4.0.1. 1. 项目结构
    2. 4.0.2. 2. pom.xml
    3. 4.0.3. 3. 代码