慧亚实业 發表於 2023-11-23 08:30:00

SpringBoot + 通义千问 + 自定义React组件,支持EventStream数据解析!

<h1 id="一前言">一、前言</h1>
<p>大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。</p>
<p>最近ChatGPT非常受欢迎,尤其是在编写代码方面,我每天都在使用。随着使用时间的增长,我开始对其原理产生了一些兴趣。虽然我无法完全理解这些AI大型模型的算法和模型,但我认为可以研究一下其中的交互逻辑。特别是,我想了解它是如何实现在发送一个问题后不需要等待答案完全生成,而是通过不断追加的方式实现实时回复的。</p>
<p>F12打开控制台后,我发现在点击发送后,它会发送一个普通的请求。但是回复的方式却不同,它的类型是eventsource。一次请求会不断地获取数据,然后前端的聊天组件会动态地显示回复内容,回复的内容是用Markdown格式来展示的。</p>
<p><img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122191526411-1148024641.png" alt="" loading="lazy"></p>
<p>在了解了前面的这些东西后我就萌生了自己写一个小demo的想法。起初,我打算使用openai的接口,并写一个小型的UI组件。然而,由于openai账号申请复杂且存在网络问题,很多人估计搞不定,所以我最终选择了通义千问。通义千问有两个优点:一是它是国内的且目前调用是免费的,二是它提供了Java-SDK和API文档,开发起来容易。</p>
<p>作为后端开发人员,按照API文档调用模型并不难,但真正难到我的是前端UI组件的编写。我原以为市面上会有很多支持EventStream的现成组件,但事实上并没有。不知道是因为这个功能太容易还是太难,总之,对接通义千问只花了不到一小时,而编写一个UI对话组件却花了整整两天的时间!接下来,我将分享一些我之前的经验,希望可以帮助大家少走坑。</p>
<p><strong>首先展示一下我的成品效果</strong><br>
<img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122174458772-1805352629.gif" alt="" loading="lazy"></p>
<h1 id="二通义千问开发key申请">二、通义千问开发Key申请</h1>
<h2 id="1-登录阿里云搜索通义千问">1. 登录阿里云,搜索通义千问</h2>
<p><img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122174914345-1602843947.png" alt="" loading="lazy"></p>
<h2 id="2-点击开通dashscope">2. 点击"开通DashScope"</h2>
<p><img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122175004938-1477173893.png" alt="" loading="lazy"></p>
<h2 id="3-创建一个api-key">3. 创建一个API-KEY</h2>
<p><img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122175104030-445173903.png" alt="" loading="lazy"></p>
<h2 id="4-对接流程">4. 对接流程</h2>
<h3 id="1api文档地址">(1)API文档地址</h3>
<p>https://help.aliyun.com/zh/dashscope/developer-reference/api-details</p>
<h3 id="2java-sdk依赖">(2)Java-SDK依赖</h3>
<pre><code class="language-xml">&lt;dependency&gt;
&lt;groupId&gt;com.alibaba&lt;/groupId&gt;
&lt;artifactId&gt;dashscope-sdk-java&lt;/artifactId&gt;
&lt;version&gt;2.8.2&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<h1 id="三支持eventstream格式的接口">三、支持EventStream格式的接口</h1>
<h2 id="1-什么是eventstream">1. 什么是EventStream</h2>
<p>EventStream是一种流式数据格式,用于实时传输事件数据。它是基于HTTP协议的,但与传统的请求-响应模型不同,它是一个持续的、单向的数据流。它可用于推送实时数据、日志、通知等,所以EventStream很适合这种对话式的场景。在Spring Boot中,主要有以下框架和模块支持EventStream格式:</p>
<ul>
<li>Spring WebFlux:Spring WebFlux是Spring框架的一部分,用于构建反应式Web应用程序。</li>
<li>Reactor:Reactor是一个基于响应式流标准的库,是Spring WebFlux的核心组件。</li>
<li>Spring Cloud Stream:Spring Cloud Stream是一个用于构建消息驱动的微服务应用的框架。</li>
</ul>
<p>这次我使用的是<code>reactor-core</code>框架。</p>
<h2 id="2-写一个例子">2. 写一个例子</h2>
<p><strong>maven依赖</strong></p>
<pre><code class="language-xml">&lt;!-- Reactor Core --&gt;
&lt;dependency&gt;
&lt;groupId&gt;io.projectreactor&lt;/groupId&gt;
&lt;artifactId&gt;reactor-core&lt;/artifactId&gt;
&lt;version&gt;3.4.6&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<p><strong>代码如下</strong></p>
<pre><code class="language-java">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&lt;String&gt; getEventStream() {
      return Flux.interval(Duration.ofSeconds(1))
                .map(sequence -&gt; "Event " + sequence + " at " + LocalTime.now());
    }
}
</code></pre>
<p>调用一下接口后就可以看到浏览器上在不断地打印时间戳了<br>
<img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122195917881-1934173948.png" alt="" loading="lazy"></p>
<h1 id="四项目实现">四、项目实现</h1>
<blockquote>
<p>这个就不BB了,直接贴代码!</p>
</blockquote>
<h2 id="1-项目结构">1. 项目结构</h2>
<p><img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122200543641-1981786890.png" alt="" loading="lazy"></p>
<h2 id="2-pomxml">2. pom.xml</h2>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;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"&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;
    &lt;parent&gt;
      &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
      &lt;artifactId&gt;spring-boot-starter-parent&lt;/artifactId&gt;
      &lt;version&gt;2.7.17&lt;/version&gt;
      &lt;relativePath/&gt; &lt;!-- lookup parent from repository --&gt;
    &lt;/parent&gt;
    &lt;groupId&gt;com.chatrobot&lt;/groupId&gt;
    &lt;artifactId&gt;demo&lt;/artifactId&gt;
    &lt;version&gt;0.0.1-SNAPSHOT&lt;/version&gt;
    &lt;name&gt;demo&lt;/name&gt;
    &lt;description&gt;Demo project for Spring Boot&lt;/description&gt;
    &lt;properties&gt;
      &lt;java.version&gt;1.8&lt;/java.version&gt;
    &lt;/properties&gt;
    &lt;dependencies&gt;
      &lt;!-- 通义千问SDK --&gt;
      &lt;dependency&gt;
            &lt;groupId&gt;com.alibaba&lt;/groupId&gt;
            &lt;artifactId&gt;dashscope-sdk-java&lt;/artifactId&gt;
            &lt;version&gt;2.8.2&lt;/version&gt;
      &lt;/dependency&gt;

      &lt;!-- Reactor Core --&gt;
      &lt;dependency&gt;
            &lt;groupId&gt;io.projectreactor&lt;/groupId&gt;
            &lt;artifactId&gt;reactor-core&lt;/artifactId&gt;
            &lt;version&gt;3.4.6&lt;/version&gt;
      &lt;/dependency&gt;

      &lt;!-- Web组件 --&gt;
      &lt;dependency&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
            &lt;exclusions&gt;
                &lt;exclusion&gt;
                  &lt;artifactId&gt;logback-classic&lt;/artifactId&gt;
                  &lt;groupId&gt;ch.qos.logback&lt;/groupId&gt;
                &lt;/exclusion&gt;
            &lt;/exclusions&gt;
      &lt;/dependency&gt;

    &lt;/dependencies&gt;

&lt;/project&gt;

</code></pre>
<h2 id="3-代码">3. 代码</h2>
<h3 id="1后端代码">(1)后端代码</h3>
<h4 id="demoapplicationjava">DemoApplication.java</h4>
<pre><code class="language-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);
    }

}
</code></pre>
<h4 id="eventcontrollerjava">EventController.java</h4>
<pre><code class="language-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&lt;ServerSentEvent&lt;String&gt;&gt; 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&lt;GenerationResult&gt; result = gen.streamCall(param);

      // 将Flowable转换成Flux&lt;ServerSentEvent&lt;String&gt;&gt;并进行处理
      return Flux.from(result)
            // add delay between each event
            .delayElements(Duration.ofMillis(1000))
            .map(message -&gt; {
                String output = message.getOutput().getChoices().get(0).getMessage().getContent();
                System.out.println(output); // print the output
                return ServerSentEvent.&lt;String&gt;builder()
                  .data(output)
                  .build();
            })
            .concatWith(Flux.just(ServerSentEvent.&lt;String&gt;builder().comment("").build()))
            .doOnError(e -&gt; {
                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&lt;String&gt; testEventStream() {
      return Flux.interval(Duration.ofSeconds(1))
            .map(sequence -&gt; "Event " + sequence + " at " + LocalTime.now());
    }
}
</code></pre>
<h3 id="2前端代码">(2)前端代码</h3>
<h4 id="chathtml">chat.html</h4>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;ChatBot&lt;/title&gt;
    &lt;style&gt;
      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;
      }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class="chat-bot"&gt;
    &lt;div class="chat-bot-header"&gt;
      &lt;img src="./logo.svg" alt="Logo" class="logo" /&gt;
      通义千问
    &lt;/div&gt;
    &lt;div class="chat-bot-messages"&gt;&lt;/div&gt;
    &lt;div class="chat-bot-input"&gt;
      &lt;input type="text" placeholder="输入你想问的问题" /&gt;
      &lt;button id="sendButton"&gt;Send&lt;/button&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;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"
&gt;&lt;/script&gt;
&lt;script&gt;
    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 = `&lt;div class="message-avatar-inner ${sender}"&gt;&lt;/div&gt;&lt;div class="userName"&gt;通义千问&lt;/div&gt;`;
                  messagesContainer.appendChild(messageElement);
                } else {
                  messageElement = document.createElement("div");
                  messageElement.classList.add("message-avatar", sender);
                  messageElement.innerHTML = `&lt;div class="message-avatar-inner ${sender}"&gt;&lt;/div&gt;&lt;div class="userName""&gt;${userName}&lt;/div&gt;`;
                  messagesContainer.appendChild(messageElement);
                }
                messageElement = document.createElement("div");
                messageElement.classList.add("message-wrapper", sender);
                messageElement.innerHTML = `&lt;div class="message ${sender}"&gt;&lt;/div&gt;`;
                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 = '&lt;div class="loading-spinner"&gt;&lt;/div&gt;';
                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" &amp;&amp; !sendButton.disabled) {
                handleSend();
            }
      });
    });
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;

</code></pre>
<p>另外还有两个头像,大家可以替换成自己喜欢的,好了文章到这里也就结束了,再秀一下我的成品👉<br>
<img src="https://img2023.cnblogs.com/blog/1127399/202311/1127399-20231122201657055-964400571.png" alt="" loading="lazy"></p>


</div>
<div id="MySignature" role="contentinfo">
    <p>本文来自博客园,作者:sum墨,转载请注明原文链接:https://www.cnblogs.com/wlovet/p/17849779.html</p><br><br>
来源:https://www.cnblogs.com/wlovet/p/17849779.html
頁: [1]
查看完整版本: SpringBoot + 通义千问 + 自定义React组件,支持EventStream数据解析!