SpringBoot + 通義千問 + 自定義React元件,支援EventStream資料解析!

sum墨發表於2023-11-23

一、前言

大家好!我是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>

另外還有兩個頭像,大家可以替換成自己喜歡的,好了文章到這裡也就結束了,再秀一下我的成品?

相關文章