从零实现一个GPT 【React + Express】--- 【1】初始化前后端项目,实现模型接入+SSE

1 阅读6分钟

摘要

本系列文章主要是实现一个能够对话以及具有文生图等功能的模型应用。主要UI界面会参考chat-gpt,豆包等系列应用。模型使用的是gpt开源的大模型。

如果你是一个前端开发工程师需要一个自己的开源项目,可以学习这个系列的文章,不需要有很完整的后端知识也可以完成。前端部分主要是通过React来实现,后端部分通过express来实现。

本篇侧重点

【1】如何通过node端连接openai的模型
【2】如何在node端如何实现SSE
【3】如何在前端通过Post请求实现SSE
【4】前端如何解析SSE返回的数据格式

获取api key

在实现项目之前,首先第一步就是要获取一个openai的 api key,因为要使用openai的模型,这个是必须的。但是由于openai在国内是访问不了的,所以这里推荐一个可以国内访问的。

github.com/chatanywher…

这个github上可以申请一个免费的api,但是需要有一个github账号。申请完之后请记住自己的api。

这个开源项目也提供了文档可以查看:

chatanywhere.apifox.cn/

初始化前端项目

这里我们使用React + vite 来构建前端项目,如果你不熟悉vite可以先看一下vite的官方文档: cn.vite.dev/guide/

你可以通过以下指令创建一个vite项目,然后根据提示一步一步的走完。

npm create vite

这里注意你的node版本要在20以上,我使用的是node22。

然后你可以根据自己的喜好去配置eslint等代码规范工具,这里就不做详细解释了。

初始化后端项目

后端项目对我来讲就是能用就行,所以我使用了express来实现。可以通过express生成器来生成一个express项目:

npm install -g express-generator

npx express-generator

生成好的express项目应该是类似以下的目录结构

image.png

在router下我们新建一个chat.js用来实现对话的接口。再在app.js中引入进来。

这里我们用了一些转换数据格式的中间件。

// app.js

const express = require('express');
const app = express();
const chatRouter = require('./routes/chat');
const port = 3002;

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/', chatRouter);

app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
})

实现SSE对话接口

Ok,现在我们前期的准备工作已经做好。你可以在豆包或者gpt里发送一段query,你会发现回答的内容是通过打字机的效果实现的。

如果查看接口的话,就会发现后端的返回结果是通过流试返回的。那后端是如何实现流试返回的效果的呢,其实简单来讲,就是在一个http请求里,通过循环不停地给前端发送消息。从而实现流试输出的效果。这个就是SSE,当然如果想了解更多内容,可以在网上查阅相关文章。

当然你需要设置正确的响应头,在chat.js中,需要正确设置text/event-stream:

// routers/chat.js

router.post('/chat', function(req, res) {
    res.set('Content-Type', 'text/event-stream;charset=utf-8');
    res.set('Access-Control-Allow-Origin', '*');
    res.set('X-Accel-Buffering', 'no');
    res.set('Cache-Control', 'no-cache, no-transform');
    const { message } = req.body;

    getChat(message, res);
});

在router里可以通过使用cors来解决跨域问题。

getChat方法就是我们要使用模型返回结果的方法,接受两个参数,一个是用户发送的query,还有就是res用来给前端返回结果。

那如果我们的后端就需要把用户发送的query传递给模型,并且要求模型流试返回。这个时候就需要用到刚刚的api key了。但是我们先装一下openai的node包,输入以下指令进行安装:

npm i openai

之后在代码顶部进行引入:

const OpenAi = require('openai');

const client = new OpenAi({
    apiKey: process.env.OPENAI_API_FREE_KEY, // 使用环境变量加载 API 密钥
    baseURL: 'https://api.chatanywhere.tech/v1',
})

这里我通过环境变量加载apiKey,读者如果是自己使用可以直接写死或者也通过环境变量。

有了openAi的实例后,我们就可以用官方的api实现我们的getChat方法了:

const getChat = async (message, res) => {
    try {
        const stream = await client.chat.completions.create({
            messages: [
                { role: 'system', content: '你是一个风趣幽默的中文助手' },
                { role: 'user', content: message },
            ],
            model: 'gpt-3.5-turbo',
            stream: true,
        });

        for await (const part of stream) {
            const eventName = 'message';
            if (Object.keys(part.choices[0]?.delta || {}).length > 0) {
                console.log(part.choices[0].delta);
                res.write(`event: ${eventName}\n`);
                res.write(`data: ${JSON.stringify(part.choices[0].delta)}\n\n`);
            }
        }
        res.end(); // 结束连接
    } catch (error) {
        console.error('Error during OpenAI API call:', error);
        res.end(); // 结束连接
    }
};

可以看到,我们在getChat方法里就是不断的从openai的流试结果里,拿到后通过res.write方式返回给前端。这样,我们就实现了一个简单的后端模型sse返回。

这里注意一下,我们通过 res.write(event: ${eventName}\n) 来定义了返回内容的类型,这个类型后面可能不止这一种。

在第一篇文章中,我们的后端部分就实现这些,就可以完成一个基本的对话了。

如果想看这部分的内容,可以通过以下的commit查看:

github.com/TeacherXin/…

实现前端布局

前端的整体布局我就不通过代码去讲解了,可以直接查看我的提交记录,这里放一下我的基本布局:

主要就是整体分为两部分,左侧侧边栏,右侧主体部分。可以给外层容器设置display:flex,然后侧边栏定宽,主体部分设置flex:1。

然后在主体部分里加入一个输入框即可。

image.png

现在我们主要来实现一下输入框这个组件,组件内部维护两个属性(暂定)。

  • inputValue
  • inputLoading

分别代表输入框的内容,和发送query之后的loading状态。

状态我们使用zustand来进行管理,当然如果读者比较熟悉mobx或者redux,可以使用自己的方式来进行状态管理。
我这里使用zustand。

// DialogInput/store.ts

import { create } from 'zustand';

interface DialogInputStore {
    inputValue: string;
    setInputValue: (value: string) => void;
    inputLoading: boolean;
    setInputLoading: (value: boolean) => void;
}

export const useDialogInputStore = create<DialogInputStore>((set) => ({
    inputValue: '',
    inputLoading: false,
    setInputValue: (value: string) => set({ inputValue: value }),
    setInputLoading: (value: boolean) => set({ inputLoading: value }),
}));

实现前端SSE

现在我们只需要在点击发送按钮的时候,把输入的query发送给后端即可。但是前端发送SSE请求,我们常见的是通过new EventSource来实现。但是这种实现方式只能通过get请求,而我们后面实现的过程可能需要传递的参数会很多。所以不推荐。

这里我们直接通过post来实现一个sse请求,我们新建一个utils文件夹然后新建一个sse.ts。

// utils/sse.ts

interface CallBackMap {
    major: (data: Major) => void;
    message: (data: Message) => void;
    close: () => void;
}

interface Major {
    id: string;
}

interface Message {
    content: string;
}

interface SendData {
    model: string;
}

const connectSSE = async (url: string, params: SendData, callbackMap:CallBackMap) => {

}

我们需要实现一个connectSSE方法,接受一个url参数,还有发送的信息,以及callbackMap。

callbackMap是针对于不同类型的返回值做出不同的处理。简单解释一下:

在实现后端sse的时候我们通过 res.write(event: ${eventName}\n)来定义不同返回值的类型,所以这里我们要根据不同的类型处理不同的操作。这里我们暂定有三个事件类型:major,message,close。

message代表的是模型返回内容时触发的事件;close代表模型结果全部返回完之后的事件。major可以先不必关注,后续会用得到。

现在我们就可以实现connectSSE方法了:

// utils/sse.ts

const connectSSE = async (url: string, params: SendData, callbackMap:CallBackMap) => {
    try {
        const res = await fetch(url, {
            headers: {
                'Content-Type': 'application/json', // 必须设置
                Accept: 'text/event-stream',
                'Cache-Control': 'no-cache',
            },
            method: 'POST',
            body: JSON.stringify(params),
        });
        
        if (!res.ok) {
            throw new Error('Error connecting to SSE');
        }
        
        if (!res.body) {
            throw new Error('Error connecting to SSE');
        }

        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        
        while(true) {
            const { value, done } = await reader.read();
            if (done) {
                console.log('Stream closed');
                callbackMap.close();
                break;
            }

            const chunk = decoder.decode(value);
            console.log(chunk);
            // 解析chunk的方法
            const data = parseChunk(chunk);
            console.log(data);

            if (data.major) {
                callbackMap.major(data.major);
            }

            if (data.message) {
                callbackMap.message(data.message);
            }
        }
    } catch (error) {
        console.log('SSE error', error);
    }
};

  


const parseChunk = (chunk: string): ParseChunk => {
    let type = '';
    const lines = chunk.split('\n');
    const eventData: ParseChunk = {};

    for (let i = 0; i < lines.length; i++) {
        if (lines[i].startsWith('event: major')) {
            type = 'major';
            continue;
        }

        if (lines[i].startsWith('event: message')) {
            type = 'message';
            continue;
        }

        if (lines[i].startsWith('data: ')) {
            if (type === 'message' && eventData[type]) {
                eventData[type]!.content += JSON.parse(lines[i].split(': ')[1]).content;
            } else if (type === 'major' || type === 'message') {
                eventData[type] = JSON.parse(lines[i].split(': ')[1]);
            }
        }
    }
    return eventData;

};

回到输入框组件,只需要给按钮的绑定事件,调用该方法即可看到效果:

// DialogInput/index.tsx

const sendData = () => {
    const url = 'http://localhost:3002/chat';
    const sendData = {
        message: inputStore.inputValue,
    };
    connectSSE(url, sendData);
};

该方法的主要原理就是设置好请求头,通过fetch发送请求。不停的从reader中读取数据chunk,然后再通过parseChunk方法解析chunk。解析的过程也就是简单的字符串处理。

在实现connectSSE的过程,可以看到我打了很多的console.log,读者在实现的过程也可以根据这个log来观察数据的转换状态。而callbackMap我们没有传进去,后续再继续补充,这个时候可以通过NetWork来看一下接口的返回结果。

image.png

可以看到接口的返回是EventStream,而且都是message类型的消息。

具体部分可以看代码的提交记录:
github.com/TeacherXin/…