springboot+vue实现ChatGPT逐字输出打字效果

文章目录

  • 前言
  • 一、效果
  • 二、Springboot后端
    • 1.封装请求OpenAI接口的客户端
    • 2.对话处理
    • 3.对话请求接口
  • 二.Vue前端


前言

在调用OpenAI GPT接口时,如果不使用流式(stream:true)参数,接口会等待所有数据生成完成后一次返回。这个等待时间可能会很长,给用户带来不良体验。

为了提升用户体验,我们需要使用流式调用方式。在这篇文章中,我们将介绍如何使用Spring Boot和Vue对接OpenAI GPT接口,并实现类似ChatGPT逐字输出的效果。


一、效果

体验地址+源码联系我。
PC端
springboot+vue实现ChatGPT逐字输出打字效果_第1张图片
移动端
springboot+vue实现ChatGPT逐字输出打字效果_第2张图片

二、Springboot后端

1.封装请求OpenAI接口的客户端

官方给的Example request:

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

根据官方示例,用java封装请求接口的客户端。本文选择使用OkHttpClient作为http请求客户端。
注意:接口调用需要魔法

GtpClient.java

@Component
public class GptClient {
    private final String COMPLETION_ENDPOINT = "https://api.openai.com/v1/chat/completions";
	// OpenAI的API key
    @Value("${gpt.api-key}")
    private String apiKey;
    // 魔法服务器地址
    @Value("${network.proxy-host}")
    private String proxyHost;
    // 魔法服务器端口
    @Value("${network.proxy-port}")
    private int proxyPort;

    OkHttpClient client = new OkHttpClient();
    MediaType mediaType;
    Request.Builder requestBuilder;

    @PostConstruct
    private void init() {
        client.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)));
        client.setConnectTimeout(60, TimeUnit.SECONDS);
        client.setReadTimeout(60, TimeUnit.SECONDS);
        mediaType = MediaType.parse("application/json; charset=utf-8");
        requestBuilder = new Request.Builder()
                .url(COMPLETION_ENDPOINT)
                .header("Content-Type", "application/json")
                .header("Authorization", "Bearer " + apiKey);
    }

    /**
     * 聊天接口
     * @param requestBody 聊天接口请求体
     * @return 接口请求响应
     */
    public Response chat(ChatRequestBody requestBody) throws ChatException {
        RequestBody bodyOk = RequestBody.create(mediaType, requestBody.toString());
        Request requestOk = requestBuilder.post(bodyOk).build();

        Call call = client.newCall(requestOk);
        Response response;
        try {
            response = call.execute();
        } catch (IOException e) {
            throw new ChatException("请求时IO异常: " + e.getMessage());
        }
        if (response.isSuccessful()) {
            return response;
        }
        try(ResponseBody body = response.body()) {
            throw new ChatException("chat api 请求异常, code: " + response.code() + "body: " + body.string());
        } catch (IOException e) {
            throw new ChatException("请求后IO异常: " + e.getMessage());
        }
    }

}

请求体封装
ChatRequestBody .java

@Data
public class ChatRequestBody {
    private static String model = "gpt-3.5-turbo";
    private static boolean stream = true;
    // 对话上下文,详情请看OpenAI接口文档
    private List<MessageItem> messages;
    @Override
    public String toString() {
        return "{\"model\":\"" + model +"\"," +
                "\"messages\":" + JSON.toJSONString(messages) + "," +
                "\"stream\":"+ stream +"}";
    }
}

2.对话处理

调用OpenAI接口可以看到,Content-Type 为 text/event-stream。
springboot+vue实现ChatGPT逐字输出打字效果_第3张图片
它指示服务器返回的响应体是一个流式事件的序列。这个响应体通常被用于服务器向客户端推送实时事件,客户端可以通过一个持久连接(HTTP 长轮询)来接收这些事件。
我们后端请求OpenAI接口,接口会通过多次向我们后端发送数据,数据格式如下:

data: {
    "id": "chatcmpl-7CgfIDnXzGXreE5LbTnM7GFnqd8ZH",
    "object": "chat.completion.chunk",
    "created": 1683258296,
    "model": "gpt-3.5-turbo-0301",
    "choices": [
        {
            "delta": {
                "content": "你"
            },
            "index": 0,
            "finish_reason": null
        }
    ]
}

发送的数据都会追加在响应体(Response)中的body(ResponseBody)中, 从中可以获取到InputStream,这就是OpenAI向我们后端发送数据的数据流了。
为了方便获取每行的数据,我们将这个流封装成BufferedReader,使用它的readLine()方法,获取每行数据(见下文中ConverseHandleWrapper.java下的run()方法),每次调用此方法都会得到一行内容(编码好的String,每行内容如上文JSON或换行符,出现换行符的原因是SSE协议导致的),这里每一行内容称之为line。我们循环调用BufferedReader的readLine()方法,即可实时获取到OpenAI接口发送来的每一行数据line。直至获取到null值,表明数据传输完毕。
其实line中的绝大数内容都是无用的,只有choices[0].delta.content字段(上文JSON中的)是我们想要的内容。我们只要这个字段值(即上文JSON中的‘你’)即可。笔者用了java.util.regex.Pattern来匹配这个content字段中的内容:

Pattern contentPattern = Pattern.compile("\"content\":\"(.*?)\"}");
Matcher matcher = contentPattern.matcher(line);
matcher.find();
String content = matcher.group(1); // content就是json中choices[0].delta.content字段的值,即上文JSON中的‘你’

我们再将每个content实时的发送到前端即可,那么如何通过http分批次的实时的将数据发送给前端呢?模仿我们调用的OpenAI的接口就好了。即SSE(Server-Sent Events)事件流连接。SSE 是一种基于 HTTP 的推送技术,它允许服务器在数据准备好时将事件推送到客户端,而不需要客户端发送请求。
SseEmitter 是 Spring 框架提供的一个异步的响应对象,它可以用于向客户端发送 SSE 事件流。当在控制器方法(Controller)中创建一个 SseEmitter 对象并返回它时,Spring MVC 将自动将响应类型设置为 “text/event-stream”,以支持 SSE 事件流协议。例如:

@RestController
public class EventController {
    @RequestMapping("/events")
    public SseEmitter handleEvents() {
        SseEmitter emitter = new SseEmitter();
        // 在这里设置 SSE 事件流的处理逻辑,例如推送实时事件
        // 我们可以在这里请求OpenAI接口,实时的获取OpenAI分批次发送来的数据,再通过emitter的send()方法实时发送给前端
        // 注意:异步处理!!!
        return emitter;
    }
}

笔者对对话处理做了封装,前端每次请求对话时,都封装成一个对象,对话的所有处理都在这个对象中进行。对话的所有处理包括用户鉴权、用户状态维护等。

对话处理的封装,内容过长,删除了部分代码,核心代码为run()方法
注意对话的处理需要是异步的,这里将对话处理放到了线程池中处理
ConverseHandleWrapper.java

@Slf4j
public class ConverseHandleWrapper{
    public static GptClient gptClient;
    public static RightsManager rightsManager;
    private static final ExecutorService executorService = Executors.newFixedThreadPool(10);
    private final static Pattern contentPattern = Pattern.compile("\"content\":\"(.*?)\"}");
    private final static String EVENT_DATA = "d";
    private final static String EVENT_ERROR = "e";
    // 用于数据传输的 SseEmitter
    private final SseEmitter emitter = new SseEmitter(0L);
    // 用户唯一标识
    private String userKey;
    // 对话上下文
    private List<MessageItem> messageItemList;

    /**
     * 向客户端发送数据
     * @param event 事件类型
     * @param data 数据
     */
    private boolean sendData2Client(String event, String data) {
        try {
            emitter.send(SseEmitter.event().name(event).data("{" + data + "}"));
            return true;
        } catch (IOException e) {
            log.error("向客户端发送消息时出现异常");
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 对话上下文检查
     * @return 是否通过
     */
    private boolean messageListCheck() {
    }

    /**
     * 对话处理
     * @return SseEmitter
     */
    public SseEmitter handle() {
        if (!messageListCheck() || !authenticate()) {
            return emitter;
        }
        rightsManager.lockUserKey(userKey);
        doConverse();
        return emitter;
    }

    /**
     * 鉴权
     * @return 是否通过
     */
    public boolean authenticate() {
    }

    /**
     * 对话,异步的,在新的线程的
     */
    public SseEmitter doConverse() {
        executorService.execute(this::run);
        return emitter;
    }

    private void run() {
        ChatRequestBody chatRequestBody = new ChatRequestBody();
        chatRequestBody.setMessages(messageItemList);
        Response chatResponse;
        try {
            chatResponse = gptClient.chat(chatRequestBody);
        } catch (ChatException e) {
            sendData2Client(EVENT_ERROR, "我累垮了");
            emitter.complete();
            e.printStackTrace();
            return;
        }
        try (ResponseBody responseBody = chatResponse.body();
             InputStream inputStream = responseBody.byteStream();
             BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                if (StringUtils.hasLength(line)) {
                    Matcher matcher = contentPattern.matcher(line);
                    if (matcher.find()) {
                        String content = matcher.group(1);
                        if (!sendData2Client(EVENT_DATA, content)) {
                            break;
                        }
                    }
                }
            }
        } catch (IOException e) {
            log.error("ResponseBody读取错误");
            e.printStackTrace();
        } finally {
            emitter.complete();
            // 用户权限相关,可以忽略
            rightsManager.decrementUsage(userKey);
            rightsManager.unlockUserKey(userKey);
        }
    }
}

通过SseEmitter的send()方法向前端发送事件流时,可以指定事件(event),上述代码中区分了两种事件:e和d。e代表这个事件是错误提示数据,d代表是正常的数据。这样前端可以通过这个字段做出判断。例如以下错误提示效果:
springboot+vue实现ChatGPT逐字输出打字效果_第4张图片

3.对话请求接口

在2中,将绝大部分的对话处理都封装好了,所以,当前端请求对话时,创建一个ConverseHandleWrapper对象并操作即可。

对话请求接口
ChatController.java

@RestController
@RequestMapping("chat")
public class ChatController {
 @PostMapping("/converse")
    public SseEmitter converseEvents(@RequestBody ConverseRequestBody requestBody) {
        // 封装对话处理
        ConverseHandleWrapper converseHandleWrapper =
                new ConverseHandleWrapper(requestBody.getUserKey(), requestBody.getMessageList());
        return converseHandleWrapper.handle();
    }
}

ConverseRequestBody.java

@Data
public class ConverseRequestBody {
    private String userKey;
    private List<MessageItem> messageList;
}

MessageItem.java

@Data
public class MessageItem {
    /**
     * 角色,user,assistant,system
     */
    private String role;
    /**
     * 内容
     */
    private String content;
}

二.Vue前端

待更新…

你可能感兴趣的:(ChatGPT搭建,spring,boot,vue.js,chatgpt)