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

摘要

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

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

本篇侧重点

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

获取api key

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

https://github.com/chatanywhere/GPT_API_free

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

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

https://chatanywhere.apifox.cn/

初始化前端项目

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

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

npm create vite

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

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

初始化后端项目

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

npm install -g express-generatornpx express-generator

生成好express项目后

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

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

// app.jsconst 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.jsrouter.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查看:

https://github.com/TeacherXin/gpt-xin-server/commit/2c9d3a311793bf97c2777b212887d637abe58c74

实现前端布局

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

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

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

在这里插入图片描述

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

  • inputValue
  • inputLoading

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

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

// DialogInput/store.tsimport { 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.tsinterface 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.tsconst 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.tsxconst 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来看一下接口的返回结果。

在这里插入图片描述

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

具体部分可以看代码的提交记录:
https://github.com/TeacherXin/gpt-xin/commit/8485c8bbf0b5017fedfbbfe83ea265e83412f6f9

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/bicheng/88408.shtml
繁体地址,请注明出处:http://hk.pswp.cn/bicheng/88408.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【PTA数据结构 | C语言版】在顺序表 list 的第 i 个位置上插入元素 x

本专栏持续输出数据结构题目集&#xff0c;欢迎订阅。 文章目录题目代码题目 请编写程序&#xff0c;将 n 个整数存入顺序表&#xff0c;对任一给定整数 x&#xff0c;将其插入顺序表中指定的第 i 个位置。注意&#xff1a;i 代表位序&#xff0c;从 1 开始&#xff0c;不是数…

汽车智能化2.0引爆「万亿蛋糕」,谁在改写游戏规则?

进入2025年&#xff0c;长安、奇瑞、比亚迪等各大主机厂纷纷将智能化推进至全新高度&#xff0c;中国汽车智能化竞争进入了“技术市场生态”综合较量阶段。一方面&#xff0c;各大主机厂全力推进辅助驾驶的规模化普及&#xff0c;掀起了一场关于高阶辅助驾驶的“技术平权”革命…

QT 第八讲 --- 控件篇 Widget(三)界面系列

前言&#xff1a; 在上一讲《QT 第七讲 --- 控件篇 &#xff08;二&#xff09;window系列与qrc机制》中&#xff0c;我们探讨了应用程序窗口&#xff08;QMainWindow, QWidget&#xff09;的基础结构、窗口标志、状态以及Qt强大的资源管理机制&#xff08;.qrc文件&#xff0…

广州华锐互动:AR 领域的创新与服务先锋​

&#xff08;一&#xff09;定制化服务​ 广州华锐互动秉持 “以客户为中心” 理念&#xff0c;为客户提供高度定制化 AR 解决方案。项目初期&#xff0c;通过多种方式深入了解客户需求&#xff0c;挖掘痛点。基于需求分析&#xff0c;技术团队运用自主研发技术和先进算法&…

暑假算法日记第一天

目标​&#xff1a;刷完灵神专题训练算法题单 阶段目标&#x1f4cc;&#xff1a;【算法题单】滑动窗口与双指针 LeetCode题目:1456. 定长子串中元音的最大数目643. 子数组最大平均数 I1343. 大小为 K 且平均值大于等于阈值的子数组数目2090. 半径为 k 的子数组平均值2379. 得…

【软考高项】信息系统项目管理师-第1章 信息化发展(1.5 数字化转型与元宇宙、1.6 标题类知识点、1.7 十四五规划内容汇总)

文章大纲 第1章 信息化发展1.5 数字化转型与元宇宙1.5.1 数字化转型1.5.2 元宇宙1.6 标题类知识点1.7 十四五规划内容汇总1.8 10道试题第1章 信息化发展 学习建议: 此章内容大部分为新增内容,基本是全新的章节2023年5月考试2分选择,5分案例2023年下半年各批次选择题2分左右1.…

STM32F103C8T6单片机内部执行原理及启动流程详解

引言&#xff1a;为什么深入理解STM32启动流程很重要&#xff1f;STM32F103C8T6作为嵌入式开发中最常用的单片机之一&#xff0c;其内部执行原理和启动流程是理解嵌入式系统底层运行机制的核心。无论是开发Bootloader、调试HardFault异常&#xff0c;还是优化系统启动速度&…

【python 常用的数学科学/计算机视觉等工具】

当然有&#xff01;在科学计算、机器学习、图像处理等领域&#xff0c;scikit-learn、scikit-image&#xff08;skimage&#xff09;、SciPy、OpenCV 是非常重要的库&#xff0c;但它们不是唯一的。以下是一些与它们类似或互补的项目&#xff0c;按照用途分类列出&#xff1a; …

LUMP+NFS架构的Discuz论坛部署

一、配置准备 每台主机都安装mysql、nfs、php、mysql 对每台主机都进行关闭防火墙、上下文等&#xff0c;减少阻碍[rooteveryone ~]# systemctl stop firewalld [rooteveryone ~]# setenforce 0安装插件等[rootlocalhost mysql]# yum install -y nfs-utils nginx [rootlocalho…

C++STL-deque

一.基础概念deque和vector一样都是对元素的操作&#xff0c;不同点&#xff1a;vector对元素增删后元素会往前或往后移&#xff0c;如果数据不大没有太多影响&#xff0c;如果数据很大效率会变低&#xff1b;deque对元素增删不会使元素位置改变&#xff0c;所有效率会变高。二.…

字节跳动高质量声音克龙文字转语音合成软件MegaTTS3整合包

MegaTTS3是抖音团队联合国内其他大学研发的一款语音合成及声音克龙应用&#xff0c;可实现零样本语音克龙及富有情感的自然语音合成。我基于当前最新版制作了免安装一键启动整合包。 MegaTTS3介绍 MegaTTS 3 是字节跳动&#xff08;ByteDance&#xff09;与浙江大学联合开发的…

RPC:远程过程调用机制

目录 1、概念 2、RPC架构 2.1 RPC的四个核心组件 2.2 访问流程 3、关键概念 3.1 接口定义语言 (IDL - Interface Definition Language) 3.2 序列化与反序列化 (Serialization & Deserialization - Marshalling/Unmarshalling) 3.3 网络传输 (Transport) 3.4 服务发…

EPLAN 电气制图(六):电机正反转副勾主电路绘制

一、项目背景&#xff1a;为什么绘制电机正反转主电路&#xff1f; 在多功能天车系统中&#xff0c;电机正反转控制是核心功能之一。通过 EPLAN 绘制主电路&#xff0c;不仅能清晰展示电源分配、换相逻辑和线缆连接&#xff0c;还能为后续 PLC 控制设计奠定基础。本次以西门子设…

JAVA JVM对象的实现

jvm分配内存给对象的方式1. 内存分配的总体流程对象内存分配的主要步骤&#xff1a;类加载检查&#xff1a;确认类已加载、解析和初始化。内存分配&#xff1a;根据对象大小&#xff0c;从堆中划分内存空间。内存初始化&#xff1a;将分配的内存空间初始化为零值&#xff08;不…

CVE-2023-41990/CVE-2023-32434/CVE-2023-38606/CVE-2023-32435

CVE-2023-41990&#xff08;GitLab 命令注入漏洞&#xff09;漏洞原理CVE-2023-41990是GitLab CE/EE&#xff08;社区版/企业版&#xff09;中项目导出功能的一个命令注入漏洞。具体原理如下&#xff1a;①GitLab在导出项目时&#xff0c;会调用git命令生成项目存档&#xff08…

RAG实战指南 Day 8:PDF、Word和HTML文档解析实战

【RAG实战指南 Day 8】PDF、Word和HTML文档解析实战 开篇 欢迎来到"RAG实战指南"系列的第8天&#xff01;今天我们将深入探讨PDF、Word和HTML文档解析技术&#xff0c;这是构建企业级RAG系统的关键基础。在实际业务场景中&#xff0c;80%以上的知识都以这些文档格式…

【AXI】读重排序深度

我们以DDR4存储控制器为例&#xff0c;设计一个读重排序深度为3的具体场景&#xff0c;展示从设备如何利用3级队列优化访问效率&#xff1a;基础设定从设备类型&#xff1a;DDR4存储控制器&#xff08;支持4个存储体Bank0-Bank3&#xff09;读重排序深度&#xff1a;3&#xff…

牛马逃离北京(回归草原计划)

丰宁坝上草原自驾游攻略&#xff08;半虎线深度版&#xff09; &#x1f697; 路线&#xff1a;北京/承德 → 丰宁县城 → 半虎线 → 大滩镇&#xff08;2天1夜&#xff09; &#x1f3af; 核心玩法&#xff1a;免费草原、高山牧场、日落晚霞、牧群互动、星空烟花&#x1f33f;…

【前端】【Echarts】ECharts 词云图(WordCloud)教学详解

效果ECharts 词云图&#xff08;WordCloud&#xff09;教学详解 词云图是一种通过关键词的大小、颜色等视觉差异来展示文本数据中词频或权重的图表。它直观、形象&#xff0c;是数据分析和内容展示中的利器。 本文将带你从零开始&#xff0c;学习如何用 ECharts 的 WordCloud 插…

【arXiv 2025】新颖方法:基于快速傅里叶变换的高效自注意力,即插即用!

一、整体介绍 The FFT Strikes Again: An Efficient Alternative to Self-AttentionFFT再次出击&#xff1a;一种高效的自注意力替代方案图1&#xff1a;FFTNet整体流程&#xff0c;包括局部窗口处理&#xff08;STFT或小波变换&#xff0c;可选&#xff09;和全局FFT&#xff…