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 outputreturn 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>
<scriptsrc="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>

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/212643.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

vue + docxtemplater 导出 word 文档

一、痛点 word 导出 这种功能其实之前都是后端实现的&#xff0c;但最近有个项目没得后端。所以研究下前端导出。 ps&#xff1a; 前端还可以导出 pdf&#xff0c;但是其分页问题需要话精力去计算才可能实现&#xff0c;并且都不是很完善。可参考之前的文章&#xff1a;利用 h…

PostgreSQL Patroni 3.0 新功能规划 2023年 纽约PG 大会 (音译)

开头还是介绍一下群&#xff0c;如果感兴趣PolarDB ,MongoDB ,MySQL ,PostgreSQL ,Redis, Oceanbase, Sql Server等有问题&#xff0c;有需求都可以加群群内有各大数据库行业大咖&#xff0c;CTO&#xff0c;可以解决你的问题。加群请联系 liuaustin3 &#xff0c;&#xff08;…

81基于matlab GUI的图像处理

基于matlab GUI的图像处理&#xff0c;功能包括图像颜色处理&#xff08;灰度图像、二值图像、反色变换、直方图、拉伸变换&#xff09;&#xff1b;像素操作&#xff08;读取像素、修改像素&#xff09;、平滑滤波&#xff08;均值平滑、高斯平滑、中值平滑&#xff09;、图像…

在arm 64 环境下使用halcon算法

背景&#xff1a; halcon&#xff0c;机器视觉领域神一样得存在&#xff0c;在windows上&#xff0c;应用得特别多&#xff0c; 但是arm环境下使用得很少。那如何在arm下使用halcon呢。按照官方说明&#xff0c;arm下只提供了运行时环境&#xff0c;并且需要使用价值一万多人民…

UE5 UI教程学习笔记

参考资料&#xff1a;https://item.taobao.com/item.htm?spma21n57.1.0.0.2b4f523cAV5i43&id716635137219&ns1&abbucket15#detail 基础工程&#xff1a;https://download.csdn.net/download/qq_17523181/88559312 1. 介绍 工程素材 2. 创建Widget UE5 UI系统的…

工业一体全国产方案,米尔T113核心板

入门级HMI屏作为嵌入式系统中重要组成部分&#xff0c;大部分都是串口屏&#xff1b;其功能简单、成本低等特点&#xff0c;使用历史悠久、应用广泛&#xff0c;而随着信息技术的快速发展&#xff0c;行业需求不断升级&#xff0c;工程师使用了大量串口屏后&#xff0c;发现串口…

请你说一下Vue中v-if和v-for的优先级谁更高

v-if 与 v-for简介 v-ifv-forv-if & v-for使用 v-if 与 v-for优先级比较 vue2 中&#xff0c;v-for的优先级高于v-if 例子进行分析 vue3 v-if 具有比 v-for 更高的优先级 例子进行分析 总结 在vue2中&#xff0c;v-for的优先级高于v-if在vue3中&#xff0c;v-if的优先级高…

加速软件开发:自动化测试在持续集成中的重要作用!

持续集成的自动化测试 如今互联网软件的开发、测试和发布&#xff0c;已经形成了一套非常标准的流程&#xff0c;最重要的组成部分就是持续集成&#xff08;Continuous integration&#xff0c;简称CI&#xff0c;目前主要的持续集成系统是Jenkins&#xff09;。 那么什么是持…

linux高级篇基础理论六(firewalld,防火墙类型,,区域,服务端口,富语言)

♥️作者&#xff1a;小刘在C站 ♥️个人主页&#xff1a; 小刘主页 ♥️不能因为人生的道路坎坷,就使自己的身躯变得弯曲;不能因为生活的历程漫长,就使求索的 脚步迟缓。 ♥️学习两年总结出的运维经验&#xff0c;以及思科模拟器全套网络实验教程。专栏&#xff1a;云计算技…

11.docker的网络-docker0的理解及bridge网桥模式的介绍与实例

1.docker0的基本理解 安装完docker服务后&#xff0c;我们首先查看一下宿主机的网络配置 ifconfig我们可以看到&#xff0c;docker服务会默认在宿主机上创建一个虚拟网桥docker0&#xff0c;该网桥网络的名字称为docker0。它在内核层连通了其他物理或者虚拟网卡&#xff0c;这…

【JavaSE】基础笔记 - 异常(Exception)

目录 1、异常的概念和体系结构 1.1、异常的概念 1.2、 异常的体系结构 1.3 异常的分类 2、异常的处理 2.1、防御式编程 2.2、异常的抛出 2.3、异常的捕获 2.3.1、异常声明throws 2.3.2、try-catch捕获并处理 3、自定义异常类 1、异常的概念和体系结构 1.1、异常的…

Vue3中如何响应式解构 props

目录 1&#xff0c;前言2&#xff0c;解决2.1&#xff0c;利用插件&#xff0c;实现编译时转换2.2&#xff0c;toRef 和 toRefs 1&#xff0c;前言 Vue3 中为了保持响应性&#xff0c;始终需要以 props.x 的方式访问这些 prop。这意味着不能够解构 defineProps 的返回值&#…