搭建网页版ChatGPT(三)

搭建网页版 ChatGPT(三)

前言

我们继续上一篇的内容,前面我们完成了对话功能,本小节主要介绍如何请求 chatGPT 以及如何监听 chatGPT 的响应,包括前端和后端的处理。

前端

由于项目一开始就引入了 Axios,于是就尝试看看 Axios 能不能处理 stream,于是写出如下代码:

export async function requestChatStream(
  req: ChatRequest,
  options?: {
    onMessage: (message: string, done: boolean) => void;
    onError: (error: Error, statusCode?: number) => void;
  }
) {
  console.log('[Request] ', req);

  return request(`/chat/${req.type}/legacy`, {
    method: 'POST',
    data: { message: req.content },
    headers: {
      'Content-Type': 'application/json',
    },
    responseType: 'stream',
  }).then((response) => {
    console.log('response', response);
  });
}

但是发现返回的结果却是这样的:

这里的 data 是等了一会然后返回整个数据,这并不是我们想要的,我们想要的是实时返回数据。

然后尝试使用 fetch,代码如下:

export async function requestChatStream(
  req: ChatRequest,
  options?: {
    onMessage: (message: string, done: boolean) => void;
    onError: (error: Error, statusCode?: number) => void;
  }
) {
  try {
    const res = await fetch(`${baseURL}/chat/ask`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ message: req.message }),
    });
    let responseText = '';
    if (res.ok) {
      const reader = res.body?.getReader();
      const decoder = new TextDecoder();
      while (true) {
        const content = await reader?.read();
        if (!content || !content.value) {
          break;
        }
        const text = decoder.decode(content.value, { stream: true });
        responseText += text;
        const done = content.done;
        options?.onMessage(responseText, false);
        if (done) {
          break;
        }
      }
      options?.onMessage(responseText, true);
    } else {
      console.error('Stream Error', res.body);
      options?.onError(new Error('Stream Error'), res.status);
    }
  } catch (err) {
    console.error('NetWork Error', err);
    options?.onError(err as Error);
  }
}

发现 fetch 是满足我们需求的,但是后面发现 EventSource 才是专门处理 stream 的,但是 fetch 现在也没发现什么问题,所以就先用着吧。

后端

以下是后端的处理逻辑,主要是 SseEmitter 的处理。

@Slf4j
@RestController
@RequestMapping("/chat")
public class OfficialChatController {

    @Autowired
    private OfficialChatService chatService;


    @PostMapping("/ask")
    public SseEmitter legacy(@RequestBody LegacyReq request) {
        return chatService.legacyAsk(request);
    }
}

这里要注意,如果我们使用默认的 SseEmitter 很可能会出现乱码,所以我做了如下处理

/**
 * 解决乱码
 */
public class SseEmitterUTF8 extends SseEmitter {

    public SseEmitterUTF8(Long timeout) {
        super(timeout);
    }

    @Override
    protected void extendResponse(ServerHttpResponse outputMessage) {
        super.extendResponse(outputMessage);

        HttpHeaders headers = outputMessage.getHeaders();
        headers.setContentType(new MediaType(MediaType.TEXT_EVENT_STREAM, StandardCharsets.UTF_8));
    }
}

以下是 OfficialChatService

@Slf4j
@Service
public class OfficialChatServiceImpl implements OfficialChatService {

    private final CustomOkHttpExecutor httpExecutor;

    public OfficialChatServiceImpl(CustomOkHttpExecutor httpExecutor) {
        this.httpExecutor = httpExecutor;
    }

    @Override
    public SseEmitter legacyAsk(LegacyReq request) {
        SseEmitter emitter = new SseEmitterUTF8(0L);
        EmitterHandler emitterHandler = new EmitterHandler(emitter, httpExecutor);
        return emitterHandler.handle(request);
    }

}

以下是 EmitterHandler

@Slf4j
public class EmitterHandler {

    private final SseEmitter emitter;

    private final CustomOkHttpExecutor httpExecutor;

    public EmitterHandler(SseEmitter emitter, CustomOkHttpExecutor httpExecutor) {
        this.emitter = emitter;
        this.httpExecutor = httpExecutor;
    }

    public SseEmitter handle(LegacyReq request) {
        askOpenAi(request);
        return emitter;
    }

    private void askOpenAi(LegacyReq request) {
        HttpRequest httpRequest = buildHttpRequest(request);
        Response chatResponse;
        try {
            chatResponse = httpExecutor.chat(httpRequest);
        } catch (HttpException e) {
            emitter.complete();
            e.printStackTrace();
            return;
        }

        try (ResponseBody responseBody = chatResponse.body();
             InputStream inputStream = responseBody.byteStream();
             BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {

            String line;
            boolean first = true;
            while ((line = bufferedReader.readLine()) != null) {
                if (StringUtils.hasLength(line)) {
                    if (!printMessage(!first ? "\n" + line: line)) {
                        break;
                    }
                }
                first = false;
            }
        } catch (IOException e) {
            log.error("ResponseBody读取错误");
            e.printStackTrace();
        } finally {
            emitter.complete();
        }
    }

    private boolean printMessage(String reply) {
        try {
            char[] messages = reply.toCharArray();
            for (char message : messages) {
                TimeUnit.MILLISECONDS.sleep(20);
                if (!sendData2Client(String.valueOf(message))){
                    return false;
                }
            }
            return true;
        } catch (InterruptedException e) {
            log.error("ResponseBody读取错误");
            e.printStackTrace();
            return false;
        }
    }

    private boolean sendData2Client(String data) {
        try {
            String text = "{" + data + "}";
            emitter.send(SseEmitter.event().data(text));
            return true;
        } catch (IOException e) {
            log.error("向客户端发送消息时出现异常");
            e.printStackTrace();
        }
        return false;
    }

    protected HttpRequest buildHttpRequest(LegacyReq chatRequest) {
        HttpRequest httpRequest = new HttpRequest();
        httpRequest.setUrl(URL);
        httpRequest.setMethod(HttpConstant.Method.POST.name());
        httpRequest.setData(JSON.toJSON(chatRequest));
        //...
        return httpRequest;
    }
}

最后

如果喜欢我的文章,欢迎关注我,同时也欢迎关注我的个人网站,里面有多个我一直迭代的项目,

还有我的 b 站,我会定期在 b 站分享一些你意想不到的知识。