从零实现一个GPT 【React + Express】--- 【2】实现对话流和停止生成

摘要

这是本系列文章的第二篇,开始之前我们先回顾一下上一篇文章的内容:

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

在这一篇中,我们主要创建了前端工程和后端工程,这里贴一下我的github地址:

github.com/TeacherXin/...
github.com/TeacherXin/...

最后我们实现了前端和后端部分的SSE内容,可以通过前端发送query,后端调用gpt模型通过流试返回内容。

而在这一篇中,我们主要把对话部分给实现出来,就是通过后端返回的内容来渲染对话流。

对话流的数据结构

首先我们来到前端项目,肯定是在components下创建一个DialogCardList组件,用来展示对话。

读者可以先在豆包上发送个对话试一下,可以看到对话区域主要是通过问答对的结构展示的。就是一问一答。

所以我们很容易就能设计出来,这个对话列表的数据结构应该是一个List,List下的每一个对象包含着,id,answer,question三个属性。

所以我们可以设计一下DialogCardList组件的store:

js 复制代码
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消息:

js 复制代码
const eventName = 'major';
res.write(`event: ${eventName}\n`);
res.write(`data: ${JSON.stringify({id: Date.now()})}\n\n`);

这样我们再看一下接口的返回:

可以看到在SSE中会先返回一个major类型的消息。

本篇章里server端的内容就三行代码的修改, 具体的提交可以查看:

github.com/TeacherXin/...

实现前端对话流

现在我们已经有了对话流的数据结构,现在我们来想一下流程应该是什么样子的。

最开始肯定是在输入框里面输入内容然后发送调用chat接口了,然后服务端通过SSE返回消息内容。

我们现在有三个回调,major,message,close。这三个函数调用的时机是什么,函数需要做什么呢。我们就来模拟整个流程来讲解。

【第一步】发送消息

给dialogCardList添加一个问答对,不过这个时候只有一个question,接口还没有返回。所以answer和cardId应该为空

js 复制代码
const data = {
    message: inputStore.inputValue,
};

dialogCardListStore.addDialogCard({
    question: inputStore.inputValue,
    answer: '',
    cardId: '',
});

inputStore.setInputValue('');
inputStore.setInputLoading(true);

【第二步】设置三种事件类型的回调

js 复制代码
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组件了:

js 复制代码
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

js 复制代码
let abortController = new AbortController();

然后修改connectSSE方法,把abortController传给fetch请求:

js 复制代码
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,不然所有的请求都发不出去了:

js 复制代码
const stopSSE = () => {
    abortController.abort(); // 取消 fetch 请求
    abortController = new AbortController();
}

当inputLoading为true的时候,点击按钮就停止生成。

前端部分在这一篇的内容也就实现完了,具体的代码变更可以看下面的提交记录: github.com/TeacherXin/...

相关推荐
万少14 分钟前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL20 分钟前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl0235 分钟前
java web5(黑马)
java·开发语言·前端
Amy.Wang37 分钟前
前端如何实现电子签名
前端·javascript·html5
今天又在摸鱼39 分钟前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js
蓝婷儿41 分钟前
每天一个前端小知识 Day 21 - 浏览器兼容性与 Polyfill 策略
前端
百锦再43 分钟前
Vue中对象赋值问题:对象引用被保留,仅部分属性被覆盖
前端·javascript·vue.js·vue·web·reactive·ref
jingling5551 小时前
面试版-前端开发核心知识
开发语言·前端·javascript·vue.js·面试·前端框架
拾光拾趣录1 小时前
CSS 深入解析:提升网页样式技巧与常见问题解决方案
前端·css
莫空00001 小时前
深入理解JavaScript属性描述符:从数据属性到存取器属性
前端·面试