SpringBoot + 通义千问 + 自定义React组件,支持EventStream数据解析!
一、前言
大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。
最近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>
另外还有两个头像,大家可以替换成自己喜欢的,好了文章到这里也就结束了,再秀一下我的成品
SpringBoot + 通义千问 + 自定义React组件,支持EventStream数据解析!的更多相关文章
- 阿里版ChatGPT:通义千问pk文心一言
随着 ChatGPT 热潮卷起来,百度发布了文心一言.Google 发布了 Bard,「阿里云」官方终于也宣布了,旗下的 AI 大模型"通义千问"正式开启测试! 申请地址:http ...
- React组件开发(一)初识React
*React不属于MVC.MVVM,只是单纯的V层. *React核心是组件(提高代码复用率.降低测试难度.代码复杂度). *自动dom操作,状态对应内容. *React核心js文件:react.js ...
- 【react 样式】给react组件指定style
1.使用行内样式(优先级高) 自定义的react组件是没有style属性的,如果要给想给自定义react组件指定style,我的方法是用一个<div>包裹自定义组件,然后给div指定sty ...
- #003 React 组件 继承 自定义的组件
主题:React组件 继承 自定义的 组件 一.需求说明 情况说明: 有A,B,C,D 四个组件,里面都有一些公用的逻辑,比如 设置数据,获取数据,有某些公用的的属性,不想在 每一个 组件里面写这些属 ...
- 基于React.js网页版弹窗|react pc端自定义对话框组件RLayer
基于React.js实现PC桌面端自定义弹窗组件RLayer. 前几天有分享一个Vue网页版弹框组件,今天分享一个最新开发的React PC桌面端自定义对话框组件. RLayer 一款基于react. ...
- 自定义react数据验证组件
我们在做前端表单提交时,经常会遇到要对表单中的数据进行校验的问题.如果用户提交的数据不合法,例如格式不正确.非数字类型.超过最大长度.是否必填项.最大值和最小值等等,我们需要在相应的地方给出提示信息. ...
- 深入React组件生命周期
上篇博文使用React开发的一些注意要点对React开发的一些重点进行了简单的罗列总结,虽然也提到了React生命周期,但只略微小结,在此单独写篇React生命周期的总结. 在组件的整个生命周期中,随 ...
- 【译】参考手册-React组件
react version: 15.4.2 React.Component 组件能够让你将UI拆分为多个独立自治并可重用的部分.在 React 中提供了 React.Component. 概述 Rea ...
- 设计 react 组件
重新设计 React 组件库 诚身 7 个月前 在 react + redux 已经成为大部分前端项目底层架构的今天, 让我们再次回到软件工程界一个永恒问题的探讨上来, 那就是如何提升一个开发团队 ...
- h5 录音 自动生成proto Js语句 UglifyJS-- 对你的js做了什么 【原码笔记】-- protobuf.js 与 Long.js 【微信开发】-- 发送模板消息 能编程与会编程 vue2入坑随记(二) -- 自定义动态组件 微信上传图片
得益于前辈的分享,做了一个h5录音的demo.效果图如下: 点击开始录音会先弹出确认框: 首次确认允许后,再次录音不需要再确认,但如果用户点击禁止,则无法录音: 点击发送 将录音内容发送到对话框中.点 ...
随机推荐
- 利用文件包含漏洞包含ssh日志拿shell
今天看文章学了一招,有包含漏洞无法传文件的时候用 目标服务器环境为ubuntu,ssh登录日志文件是/var/log/auth.log 找个Linux的环境执行ssh '<? phpinfo() ...
- [golang]字符串拼接
前言 在go语言中,因为字符串只能被访问,不能被修改,所以进行字符串拼接的时候,golang都需要进行内存拷贝,造成一定的性能消耗. 方式1:操作符 + 特点:简单,可读性良好.每次拼接都会产生内存拷 ...
- pandas读取mysql并导出为excel
前言 业务需要从数据库导出数据为excel,并设置成自动化.这里用pandas写的数据导入导出,还算方便.配合crontab + shell脚本使用,每天晚上自动生成excel,然后cp到指定目录.s ...
- maven系列:基本命令(创建类、构建打包类、IDEA中操作)
目录 一.创建类命令 创建普通Maven项目 创建Web Maven项目 发布第三方Jar到本地库中 二.构建打包类命令 编译源代码 编译测试代码 编译测试代码 打包项目 清除打包的项目 清除历史打包 ...
- Web通用漏洞--RCE
Web通用漏洞--RCE 漏洞简介 RCE远程代码/命令执行漏洞,可以让攻击者直接向后台服务器远程注入操作系统命令或者代码,从而控制后台系统. RCE漏洞也分为代码执行漏洞和命令执行漏洞,所谓代码执行 ...
- module.exports和exports,应该用哪个
在 Node.js 编程中,模块是独立的功能单元,可以在项目间共享和重用.作为开发人员,模块让我们的生活更轻松,因为我们可以使用模块来增强应用程序的功能,而无需亲自编写.它们还允许我们组织和解耦代码, ...
- Job System 初探
作者:i_dovelemon 日期:2023-08-24 主题:Fiber, Atomic Operation, MPMC Queue, Multiple thread, Job system 引言 ...
- Programming abstractions in C阅读笔记:p132-p137
<Programming Abstractions In C>学习第53天,p132-p137,3.2小节"strings"总结如下: 一.技术总结 3.2小节介绍了字 ...
- 基于bert-base-chinese训练bert模型(最后附上整体代码)
目录: 一.bert-base-chinese模型下载 二.数据集的介绍 三.完成类的代码 四.写训练方法 五.总源码及源码参考出处 一.bert-base-chinese模型下载 对于已经预训练好的 ...
- C# 使用Windows身份验证连接Sql Server
C# 使用Windows身份验证连接Sql Server 使用Windows身份验证连接Sql Server 的字符串为: server=.;database=test_user;Trusted_Co ...