springboot3 + openai+event-stream 微信小程序实现ChatGPT流式响应对话

563 阅读1分钟

后台搭建:

添加依赖:

<dependency>
    <groupId>io.springboot.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.3</version>
</dependency>

添加配置:

spring:  
    ai: 
      openai: 
            api-key: 你的APi key 
           model: gpt-3.5-turbo      
           base-url: https://api.xty.app/     
           chat:  
               options:         
                   temperature: 0.7

controller:

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.openai.OpenAiChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@Slf4j
public class OpenAiController {    
private final OpenAiChatClient chatClient;    
public OpenAiController(OpenAiChatClient chatClient) {
        this.chatClient = chatClient;    }
  /**     
  * 同步方式实现聊天功能 
  *     
  * @param prompt 提示词    
  */    
  @GetMapping("/chat")    
  public String chat(String prompt) { 
       return chatClient.call(prompt);    
  }
  /***
  * 流式方式实现聊天功能 
  * @param prompt 提示词
  */    
  @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  public Flux<String> stream(String prompt) {
       log.info("prompt: {}", prompt); 
       return chatClient.stream(prompt);   
  }
}

uiapp代码:

<template>
	<view class="message-content">
		<scroll-view class="message-wrapper" :scroll-into-view="bottomId" :scroll-y="true"
			:scroll-with-animation="true">
			<block v-for="(item,index) in messageList">
				<block v-if="item.from=='robot'">
					<view class="message-item-box">
						<view class="message-item-avatar-box">
							<img class="avatar" src="@/static/tqb-tabbar/add.png" />
						</view>
						<view class="message-item-content-box">
							<view class="message-item-content-message">{{item.message}}</view>
						</view>
					</view>
				</block>
				<block v-else>
					<view class="message-item-box message-item-box-right">
						<view class="message-item-content-box">
							<view class="message-item-content-message">{{item.message}}</view>
						</view>
						<view class="message-item-avatar-box">
							<img class="avatar" src="@/static/tqb-tabbar/add2.png" />
						</view>
					</view>
				</block>
			</block>
			<block v-if="disabled && resultText.length<1">
				<view class="message-item-box">
					<view class="message-item-avatar-box">
						<img class="avatar" src="@/static/tqb-tabbar/add.png" />
					</view>
					<view class="message-item-content-box">
						<view class="message-item-content-message">
							<view class="loading">
								<view class="span"></view>
								<view class="span"></view>
								<view class="span"></view>
								<view class="span"></view>
								<view class="span"></view>
								<view class="span"></view>
								<view class="span"></view>
							</view>
						</view>
					</view>
				</view>
			</block>
			<block v-if="resultText">
				<view class="message-item-box">
					<view class="message-item-avatar-box">
						<img class="avatar" src="@/static/tqb-tabbar/add.png" />
					</view>
					<view class="message-item-content-box">
						<view class="message-item-content-message">{{resultText}}</view>
					</view>
				</view>
			</block>
			<view id="bottom" class="bottom"></view>
		</scroll-view>
	</view>
	<view class="message-clean-box" @click="cleanMessage">
		<view class="message-clean-icon icon-qingkong"></view>
		<view class="message-clean-label">清除对话</view>
	</view>
	<view class="tqb-tabbar-body">
		<input placeholder="请输入您想问的内容···" v-model="prompt" />
		<block v-if="prompt.length && !disabled">
			<view class="send-button iconfont icon-paper" @click="sendMessage">
			</view>
		</block>
		<block v-else>
			<view class="send-button iconfont icon-paper send-disabled">
			</view>
		</block>

	</view>
</template>

style:

<style lang="scss" scoped>
	.tqb-tabbar-body {
		border-radius: 80rpx;
		height: 120rpx;
		display: flex;
		justify-content: space-between;
		align-items: center;
		background: linear-gradient(to right, #ffffff, #F1EDFF);
		position: fixed;
		bottom: 68rpx;
		width: 704rpx;
		left: 24rpx;
		z-index: 1001;
		box-shadow: 0px 0px 22rpx 9rpx rgba(0, 0, 0, 0.1);
		gap: 30rpx;
	}

	input {
		display: inline-block;
		flex: 1;
		margin-left: 30rpx;
		height: 80rpx;
		font-size: 32rpx;
	}

	.send-button {
		display: inline-block;
		color: #ffffff;
		border-radius: 50%;
		width: 80rpx;
		height: 80rpx;
		background: #270ef4;
		margin-right: 30rpx;
		text-align: center;
		line-height: 80rpx;
	}

	.send-disabled {
		color: #fafafa !important;
		background: #add8e6 !important;
	}

	input::-webkit-input-placeholder {
		color: #BBBBBB !important;
		font-size: 34rpx;
	}

	.message-content {
		width: 100vw;
		height: 100vh;
		overflow: hidden;
	}

	.message-wrapper {
		width: 100vw;
		height: 100vh;
		max-width: 100vw;
		max-height: 100vh;
		background: #add8e6;

		.message-item-box:last-child {
			padding-bottom: 300rpx !important;
		}
	}

	.message-clean-box {
		position: fixed;
		align-items: center;
		gap: 6rpx;
		z-index: 1001;
		left: 30rpx;
		bottom: 200rpx;
		display: flex;
		background: linear-gradient(to right, #ffffff, #F1EDFF);
		box-shadow: 0px 0px 10rpx 6rpx rgba(0, 0, 0, 0.1);
		padding: 10rpx 20rpx;
		border-radius: 40rpx;
		font-size: 24rpx;
		color: #270ef4;

		.message-clean-label {
			letter-spacing: 6rpx;
		}
	}

	.message-item-box {
		padding: 20rpx;
		color: #270ef4;
		font-size: 30rpx;
		display: flex;
		align-items: flex-start;
		gap: 20rpx;

		.message-item-avatar-box {
			width: 80rpx;
			height: 80rpx;

			.avatar {
				width: 80rpx;
				height: 80rpx;
			}
		}

		.message-item-content-box {
			.message-item-content-message {
				font-size: 30rpx;
				word-break: break-all;
				background: #FFFFFF;
				max-width: 480rpx;
				padding: 20rpx;
				border-radius: 0 12rpx 12rpx 12rpx;
			}
		}
	}

	.message-item-box-right {
		justify-content: flex-end;
		color: #FFFFFF;

		.message-item-content-box {
			.message-item-content-message {
				background: #270ef4;
				border-radius: 12rpx 0 12rpx 12rpx;
			}
		}
	}

	.bottom {
		padding-bottom: 300rpx;
	}

	.loading .span {
		display: inline-block;
		vertical-align: middle;
		width: 6rpx;
		height: 20rpx;
		margin: 6rpx;
		background: #add8e6;
		border-radius: 2rpx;
		animation: loading-5118fab1 1s infinite alternate;
	}

	.loading .span:nth-of-type(2) {
		background: #008FB2;
		animation-delay: 0.2s;
	}

	.loading .span:nth-of-type(3) {
		background: #009B9E;
		animation-delay: 0.4s;
	}

	.loading .span:nth-of-type(4) {
		background: #00A77D;
		animation-delay: 0.6s;
	}

	.loading .span:nth-of-type(5) {
		background: #00B247;
		animation-delay: 0.8s;
	}

	.loading .span:nth-of-type(6) {
		background: #5AB027;
		animation-delay: 1.0s;
	}

	.loading .span:nth-of-type(7) {
		background: #270ef4;
		animation-delay: 1.2s;
	}
	@keyframes loading {
		0% {
			opacity: 0;
		}

		100% {
			opacity: 1;
		}
	}
</style>

javaScript:

<script>
	import BASE_API from '@/api/config.js'
	export default {
		components: {

		},
		data() {
			return {
				bottomId: "bottom",
				disabled: false,
				resultText: "",
				prompt: "",
				messageList: [
				]
			}
		},
		onLoad() {

		},
		methods: {
			cleanMessage() {
				let that = this;
				setTimeout(function() {
					that.messageList = [];
					that.bottomId = "bottom";
				}, 100);
			},
			scrollBottom() {
				let that = this;
				that.bottomId = "";
				setTimeout(function() {
					that.bottomId = "bottom";
				}, 100);
			},
			sendMessage() {
				let that = this;
				let prompt = that.prompt;
				that.messageList.push({
					"from": "",
					"message": prompt
				});
				that.disabled = true;
				that.prompt = '';
				that.scrollBottom();
				const requestTask = uni.request({
					url: BASE_API + '/stream',
					timeout: 15000,
					responseType: 'text',
					method: 'GET',
					enableChunked: true, 
					data: {
						prompt: that.prompt
					},
					header: {
						"Accept": "text/event-stream",
						"Cache-Control": "no-cache",
						"Connection": "keep-alive",
						"Content-type": "application/json"
					},
					success: response => {
						console.log("success");
						console.log(response);
						that.messageList.push({
							"from": "robot",
							"message": that.resultText
						});
						that.disabled = false;
						that.resultText = "";
						that.scrollBottom();
					},
					fail: error => {
						that.disabled = false;
						console.log("error");
						console.log(error)
					}
				})
				requestTask.onHeadersReceived(function(res) {
					that.disabled = true;
					that.prompt = '';					
				})
				requestTask.onChunkReceived((res) => {
					const uint8Array = new Uint8Array(res.data);
					let text = String.fromCharCode.apply(null, uint8Array);
					text = decodeURIComponent(escape(text));
					if (text) {
						let cleanedContent = text.replaceAll("data:", '');
						let splitContent = cleanedContent.replace(/^,/, '').replace(/\r?\n/g, '');;
						that.resultText += splitContent;						
						that.scrollBottom();
					}
				})
			}
		}
	}
</script>

本人是后端,前端不太懂,最近也是别学别写,希望大佬看了嘴下留情,效果如下:

(因为白嫖的国内gpt,用了几次就没费用,也没充值,所以所问非所答(有条件的可以自己注册一个或者搭建个ollama服务)

总结:

1.text/event-stream 是一种用于服务器向客户端推送事件的媒体类型(Media Type)。它是基于 HTTP 协议的一种流式传输技术,也被称为 Server-Sent Events(SSE)。允许客户端发起一起请求,服务端分批响应给客户端,客户端一次解析数据。SSE 响应解析流程:

  1. 先使用 \n\n 进行分割

  2. 分割的每个元素使用 \n 进行分割,从而得到 event 和 data

  3. 对 data 进行序列化解析

后端影响sse如下:

小程序请求如下:

小程序端解析如图: