摘要
這是本系列文章的第二篇,開始之前我們先回顧一下上一篇文章的內容:
從零實現一個GPT 【React + Express】— 【1】初始化前后端項目,實現模型接入+SSE
在這一篇中,我們主要創建了前端工程和后端工程,這里貼一下我的github地址:
https://github.com/TeacherXin/gpt-xin
https://github.com/TeacherXin/gpt-xin-server
最后我們實現了前端和后端部分的SSE內容,可以通過前端發送query,后端調用gpt模型通過流試返回內容。
而在這一篇中,我們主要把對話部分給實現出來,就是通過后端返回的內容來渲染對話流。
對話流的數據結構
首先我們來到前端項目,肯定是在components下創建一個DialogCardList組件,用來展示對話。
讀者可以先在豆包上發送個對話試一下,可以看到對話區域主要是通過問答對的結構展示的。就是一問一答。
所以我們很容易就能設計出來,這個對話列表的數據結構應該是一個List,List下的每一個對象包含著,id,answer,question三個屬性。
所以我們可以設計一下DialogCardList組件的store:
import { create } from 'zustand';interface DialogCard {question: string;answer: string;cardId: string;
}interface DialogCardListStore {sessionId: string;setSessionId: (id: string) => void;dialogCardList: DialogCard[];addDialogCard: (card: DialogCard) => void;changeLastAnswer: (question: string) => void;changeLastId: (id: string) => void;
}export const useDialogCardListStore = create<DialogCardListStore>((set) => ({sessionId: '',setSessionId: (id: string) => set(() => ({ sessionId: id })),dialogCardList: [],addDialogCard: (card: DialogCard) => set((state) => ({dialogCardList: [...state.dialogCardList, card],})),changeLastAnswer: (answer: string) => set((state) => {const dialogCard = state.dialogCardList[state.dialogCardList.length - 1];if (dialogCard) {dialogCard.answer += answer;}return { dialogCardList: [...state.dialogCardList] };}),changeLastId: (id: string) => set((state) => {const dialogCard = state.dialogCardList[state.dialogCardList.length - 1];if (dialogCard) {dialogCard.cardId = id;}return { dialogCardList: [...state.dialogCardList] };}),}));
dialogCardList
就是代表每個問答對組成的列表;
changeLastAnswer
方法主要是用來修改最后一個card的answer,這里是因為sse返回內容是流試的。所以我們要不停的更新最后一個節點的回答。
后端添加major事件
剛才我們說到,每個對話的card都有三個屬性,id,question,answer,那id是從哪里來的呢,肯定是后端返回的。
后端可以在每次返回模型輸出內容之前,先返回一個id。但是這個id肯定不能是message類型的,所以,我們可以在major事件里返回對應的id。
在getChat方法中,在for循環之前先發送一個major消息:
const eventName = 'major';
res.write(`event: ${eventName}\n`);
res.write(`data: ${JSON.stringify({id: Date.now()})}\n\n`);
這樣我們再看一下接口的返回:
可以看到在SSE中會先返回一個major類型的消息。
本篇章里server端的內容就三行代碼的修改,
具體的提交可以查看:
https://github.com/TeacherXin/gpt-xin-server/commit/1ecc36ceb29acec888df48102ec64edf0c3c676f
實現前端對話流
現在我們已經有了對話流的數據結構,現在我們來想一下流程應該是什么樣子的。
最開始肯定是在輸入框里面輸入內容然后發送調用chat接口了,然后服務端通過SSE返回消息內容。
我們現在有三個回調,major,message,close。這三個函數調用的時機是什么,函數需要做什么呢。我們就來模擬整個流程來講解。
【第一步】發送消息
給dialogCardList添加一個問答對,不過這個時候只有一個question,接口還沒有返回。所以answer和cardId應該為空
const data = {message: inputStore.inputValue,
};dialogCardListStore.addDialogCard({question: inputStore.inputValue,answer: '',cardId: '',
});inputStore.setInputValue('');
inputStore.setInputLoading(true);
【第二步】設置三種事件類型的回調
const url = 'http://localhost:3002/chat';const messageCallback = (message: Message) => {dialogCardListStore.changeLastAnswer(message.content);
};const closeCallback = () => {inputStore.setInputLoading(false);
};const majorCallback = (major: Major) => {dialogCardListStore.changeLastId(major.id);
};connectSSE(url, data, {message: messageCallback,major: majorCallback,close: closeCallback,
});
我們需要在messageCallback,不停的更新dialogCardList中,最后一個card的answer。
在majorCallback中,更新最后一個card的id
在closeCallback中,更新一下輸入框的loading狀態。
然后傳給connectSSE方法即可。
【第三步】實現DialogCardList組件
有了數據結構以及更新流程之后,我們就可以實現DialogCardList組件了:
const DialogCardList: React.FunctionComponent = () => {const dialogCardListStore = useDialogCardListStore();return (<div className={styles.scrollContainer}><div className={styles.dialogCardList}>{dialogCardListStore.dialogCardList.map((item) => {return (<div className={styles.dialogCard} key={item.cardId}><div className={styles.question}><p>{item.question}</p></div><div className={styles.answer}>{item.answer}</div></div>);})}</div></div>);
};
只需要遍歷dialogCardList把對應的問答對展示出來即可,CSS的樣式這里我就不寫了,可以直接看我的提交記錄(貼在后面了)。
最終我們就可以通過發送query實現對話功能了,這里展示一下效果:
停止生成
現在我們發送完對話,如何停止生成,讓這個對話結束呢。
其實我們只需要把SSE的請求取消即可,回到我們的sse.ts中,在最外層定義個abortController
let abortController = new AbortController();
然后修改connectSSE方法,把abortController傳給fetch請求:
const res = await fetch(url, {headers: {'Content-Type': 'application/json', // 必須設置Accept: 'text/event-stream','Cache-Control': 'no-cache',},method: 'POST',body: JSON.stringify(params),signal: abortController.signal, // 用于取消請求});
最后再實現一個stopSSE方法,這里注意一下,每次停止生成都要生成一個新的AbortController,因為下次發送fetch請求不能用之前的AbortController,不然所有的請求都發不出去了:
const stopSSE = () => {abortController.abort(); // 取消 fetch 請求abortController = new AbortController();
}
當inputLoading為true的時候,點擊按鈕就停止生成。
前端部分在這一篇的內容也就實現完了,具體的代碼變更可以看下面的提交記錄:
https://github.com/TeacherXin/gpt-xin/commit/6cb2c719cce51ae9cd6af92cad1283de41c485c9