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

0 阅读4分钟

摘要

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

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

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

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

最后我们实现了前端和后端部分的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`);

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

image.png

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

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

github.com/TeacherXin/…

实现前端对话流

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

最开始肯定是在输入框里面输入内容然后发送调用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实现对话功能了,这里展示一下效果:

录屏2025-07-04-14.27.45.gif

停止生成

现在我们发送完对话,如何停止生成,让这个对话结束呢。

其实我们只需要把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的时候,点击按钮就停止生成。

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