摘要
本系列文章主要是實現一個能夠對話以及具有文生圖等功能的模型應用。主要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