react使用sse流实现chat大模型问答,补充css样式

typescript 复制代码
import React, { useState, useEffect, useRef, useMemo } from 'react';
import './index.less';
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { getAccessToken, getCurrentOrganizationId } from 'hzero-front/lib/utils/utils';
import { getQuestionHistory, getTreeList, getRole, getTreeByOrgName, getTreeByUnitId } from "@/services/api-page";
import chatGPT from '@/assets/images/chatGPT.webp';
import user from '@/assets/images/user.webp';
import { Form, Tooltip, TreeSelect, useDataSet, Icon } from 'choerodon-ui/pro';
import { dataSet, optionDs } from "./store/index";
import { ButtonColor } from 'choerodon-ui/pro/lib/button/enum';
import { Button } from 'choerodon-ui/pro';
import { message } from 'hzero-ui';
import Search from 'choerodon-ui/lib/input/Search';
import { Input } from 'element-react';
import { Select } from 'element-react'
import 'element-theme-default';
const { TreeNode } = TreeSelect;
interface Message {
    errorTip: string | undefined;
    id: string;
    sender: 'user' | 'bot';
    text: string | undefined;
    files?: any[];
    fileName?: string; // 新增用于存储文件名的属性
    truncatedContent?: string; // 新增用于存储截断内容的属性
    finish?: boolean; // 新增用于存储是否完成的状态
}

const ChatApp: React.FC = () => {
    const [messages, setMessages] = useState<Message[]>([]); // 渲染的消息列表
    const [userInput, setUserInput] = useState('');
    const [showButton, setShowButton] = useState(false)
    const showData = useDataSet(dataSet, []);

    const currentBotMessageRef = useRef<{
        truncatedContent: any;
        fileName: any; id: string; text: string; files?: any[];
    }>({
        id: '',
        text: '',
        files: [], // 初始化为空数组
        truncatedContent: '',
        fileName: '',
        // id: '',
        //  text: '', 
        //  files?: any[] | undefined,
    }); // 当前正在更新的机器人消息
    const [process, setProcess] = useState([]);
    const controllerRef = useRef(new AbortController());
    const [orgId, setOrgId] = useState<string>();  //获取组织id
    const [userId, setUserId] = useState('') //当亲登录的用户id

    const [selectValue, setSelectValue] = useState() //组织机构树选择的值
    const [selectionOptions, setSelectionOptions] = useState([])  //组织机构树的数据
    const [isSelectVisible, setIsSelectVisible] = useState(false); //控制组织机构树是否显示
    // 通过sse流获取到流数据
    const startSseWithPost = async (userMessageId: string) => {
        currentBotMessageRef.current = { id: `${userMessageId}-bot`, text: '' }; // 初始化机器人消息

        await fetchEventSource(`http://${IP.TEST}${BASIC.CWDMX_MODEL}/${tenantId}/knowledgeBase/getQuestionAnswer`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + getAccessToken(),
            },
            body: JSON.stringify({
                orgId: orgId,
                question: userInput,
                stream: true,
            }),
            signal: controllerRef.current.signal,
            onmessage(event) {
                try {
                    if (event.data) {
                        try {
                            const messageData = JSON.parse(event.data)
                            const process = messageData?.message?.features?.progress;
                            setProcess(process);
                            // if (messageData?.message?.type === 'text') {
                            const content = messageData?.message?.content;

                            // 累积回答内容
                            currentBotMessageRef.current.text += content;
                            if (messageData?.message?.features && messageData?.message?.features?.doc_citations) {
                                let fileInfoString = '';

                                // 遍历 doc_citations 数组
                                messageData?.message?.features?.doc_citations.forEach(citation => {
                                    citation?.documents && citation?.documents.forEach((item) => {

                                        // setFileName(item.metadata.name)
                                        const fileName = item?.metadata?.name
                                        const truncatedContent = item?.metadata?.doc_ref?.content.length > 14 ? item.metadata?.doc_ref?.content.substring(0, 14) + '...' : item.metadata.doc_ref.content;
                                        const fileObject = {
                                            content: truncatedContent,
                                            url: item?.metadata?.doc_ref?.documentUrl

                                        };
                                        const files: any[] = currentBotMessageRef.current.files || [];
                                        files.push(fileObject)
                                        currentBotMessageRef.current.fileName = fileName;
                                        currentBotMessageRef.current.truncatedContent = truncatedContent;
                                        currentBotMessageRef?.current && (currentBotMessageRef.current.files = files)
                                    });
                                })
                                // 动态更新消息状态
                                setMessages((prevMessages) =>
                                    prevMessages.map((msg) =>
                                        msg.id === currentBotMessageRef.current.id
                                            ? {
                                                ...msg,
                                                text: `${currentBotMessageRef.current.text}\n${fileInfoString}`,
                                                fileName: currentBotMessageRef.current.fileName,
                                                truncatedContent: currentBotMessageRef.current.truncatedContent,
                                                files: currentBotMessageRef.current.files,
                                                finish: messageData?.finish, // 设置完成状态
                                                errorTip: messageData?.error
                                            }
                                            : msg
                                    )
                                );
                            } else {
                                // 动态更新消息状态,如果没有文件相关信息,只更新文本内容
                                setMessages((prevMessages) =>
                                    prevMessages.map((msg) =>
                                        msg.id === currentBotMessageRef.current.id
                                            ? {
                                                ...msg, text: currentBotMessageRef.current.text,
                                                finish: messageData?.finish,
                                                errorTip: messageData?.error
                                            }
                                            : msg
                                    )
                                );
                            }

                            // }
                        } catch (err) {
                            console.error('Error parsing message data:', err);
                        }
                    }
                } catch (error) {
                    message.error('获取失败');
                }

            },
            onerror(err) {
                console.error('SSE error:', err);
                throw (err)
            },
            onclose() {
                setShowButton(false)
                controllerRef.current.abort();
            },
        });
    };
    // 发送消息事件,包括发送的用户输入的消息
    const handleSendMessage = () => {
        setShowButton(true)
        if (userInput.trim()) {
            const userMessageId = `${Date.now()}`; // 唯一标识符
            // 插入用户消息和占位的机器人消息
            setMessages((prevMessages) => [
                ...prevMessages,
                { id: userMessageId, sender: 'user', text: userInput },
                { id: `${userMessageId}-bot`, sender: 'bot', text: '' }, // 机器人占位消息
            ]);

            startSseWithPost(userMessageId); // 启动 SSE
            setUserInput(''); // 清空用户输入
        }
    };
    //停止发送消息
    const handleStopFetch = () => {
        if (controllerRef.current) {
            controllerRef.current.abort();
        }
    }
    // 这里面可以控制node结点的判断来实现是否展示为叶结点
    const nodeCover = ({ record }) => {
        const nodeProps = {
            title: record.get('unitName'),
        };
        if (record.get('power') === 'false') {
            nodeProps.disabled = true;
        }
        return nodeProps;
    }
    // 获取选中的组织机构id,需要给问答的接口传参
    const handleChange = (val) => {
        setOrgId(val)
    }
    //获取当前登录的用户,并设置userId
    const [displayFlag, setdisplayFlag] = useState('false')  //是否展示单位
    useEffect(() => {
        console.log('logfangwende第一次');

        const user = async () => {
            const res = await getRole();
            if (res) {
                setUserId(res?.user?.id)
                if (userId) {
                    const res = await getTreeList({ guestId: userId, displayFlag: 'true' });
                    setdisplayFlag(res?.data?.displayFlag)
                }
            }
        }
        user()
    }, [userId])
    useEffect(() => {
        console.log('logfangwende第二次');

        if (displayFlag === 'true') {
            optionDs.setQueryParameter("guestId", userId);
            optionDs.query()
        }
    }, [displayFlag])
    // 获取组织树
    useEffect(() => {
        console.log('logfangwende第三次');
        const chatBox = document.querySelector('.chat-box');
        if (chatBox) {
            chatBox.scrollTop = chatBox.scrollHeight;
        }
    }, [messages]);
    // 获取问答页历史消息
    useEffect(() => {
        console.log('logfangwende第四次');

        const fetchData = async () => {
            try {
                const res = await getQuestionHistory();

                res.data.forEach((item) => {
                    if (item?.direction === 1) {
                        setMessages((prevMessages) => [
                            ...prevMessages,
                            { id: item?.mesTime, sender: 'user', text: item?.content, },
                            // { id: `${item?.mesTime}-bot`, sender: 'bot', text: '' }, // 机器人占位消息
                        ]);

                    } else if (item?.direction === 2) {
                        const fileName = item?.ref?.[0]?.title;
                        const files: any[] = []
                        item?.ref?.forEach((item) => {
                            if (item?.content) {
                                if (item?.content.length > 14) {
                                    const fileObject = {
                                        content: item?.content.substring(0, 14) + '...',
                                        url: item?.docUrl
                                    };
                                    files.push(fileObject);
                                }
                            }

                        })

                        setMessages((prevMessages) => [
                            ...prevMessages,
                            { id: `${item?.mesTime}-bot`, sender: 'bot', text: item?.content, fileName: fileName, files: files, finish: true }, // 机器人占位消息
                        ]);
                    }

                })
            } catch (error) {
                console.error('Error fetching data:', error);
            }
        };
        fetchData();
    }, []);

    // 处理选中的组织机构
    const selectChange = (val) => {
        if (!val) return
        setIsSelectVisible(false)
        setSelectValue(val)
        showData.loadData([{ unitName: val, unitCode: val }])
    }
    // 输入组织机构获取到的数据
    const handleEnter = async (value) => {
        setSelectionOptions([])
        if (value !== '') {
            const params = { guestId: userId, unitName: value }
            const result = await getTreeByOrgName(params)
            if (Array.isArray(result.data)) {
                const newArray = result.data.map(item => {
                    return {
                        value: item.unitCode,
                        label: item.unitName
                    }
                })
                setSelectionOptions(newArray)
            }
        }
    }
    //控制搜索摁扭展示
    const handleIconClick = () => {
        setSelectValue(undefined)
        setIsSelectVisible(true);
        setSelectionOptions([])
    };
    //控制树形组件和下拉选择组件的切换
    const handleVisibleChange = (isVisible) => {
        if (!isVisible) {
            setIsSelectVisible(false);
        }
    };
    // //自动聚焦操作(没实现
    // const selectRef = useRef(null);
    // useEffect(() => {
    //     if (selectRef.current) {
    //         selectRef.current?.focus();
    //     }
    // }, []);
    console.log(displayFlag, 'displayFlag');

    return (
        <div className="chat-container">
            <div className="+">
                {
                    displayFlag === 'true' && <div className='search-box'>
                        {isSelectVisible ? (
                            <Select
                                // ref={selectRef}
                                filterable={true}
                                remote={true}
                                remoteMethod={(value) => { handleEnter(value) }}
                                value={selectValue}
                                onChange={selectChange}
                                clearable={true}
                                placeholder="请输入单位名称"
                                onVisibleChange={handleVisibleChange}
                            >
                                {selectionOptions.map((el, index) => {
                                    return <Select.Option key={el?.value} label={el.label} value={el.value} />
                                })}
                            </Select>
                        ) : (
                            <>
                                <TreeSelect
                                    placeholder="请选择公司名称"
                                    name="unitName"
                                    dataSet={showData}
                                    onOption={nodeCover}
                                    onChange={handleChange}
                                    style={{ marginRight: '7px' }}
                                    popupCls={'down_select'}
                                />
                                <Icon type="search" onClick={handleIconClick} style={{ cursor: 'pointer', fontSize: '20px' }} />
                            </>
                        )}
                    </div>
                }
                {messages.map((msg) => (
                    <div key={msg.id} className={`message-wrapper ${msg.sender}`}>
                        <div className="avatar">
                            <img src={msg.sender === 'user' ? user : chatGPT} alt="avatar" />
                        </div>
                        <div className="message">
                            {msg.errorTip ?? msg.text ?? '...'}
                            {msg.sender === 'bot' && msg.fileName && msg.finish === true && <div ><br />文档来源:{msg.fileName}<br /></div>}
                            {msg.files && msg.finish === true && msg.files?.map((item, index) => {
                                const url = `https://${item?.url}`
                                return <div key={index}>
                                    {item?.content} &nbsp;&nbsp;&nbsp;&nbsp; <a href={url} target="_blank">查看文档</a>
                                </div>
                            })}
                        </div>
                    </div>
                ))}
            </div>
            <div className='tip'>
                <div className='process'>当前进度:{process}</div>
                {showButton ? <p className='stop' onClick={handleStopFetch}>停止生成</p> : ''}
            </div>
            <div className="input-box">
                <input
                    type="text"
                    value={userInput}
                    onChange={(e) => setUserInput(e.target.value)}
                    onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
                    placeholder="请输入消息"
                />
                {
                    <Tooltip title="请选择单位名称">
                        <Button color="primary" disabled={displayFlag === 'true' && !orgId} onClick={handleSendMessage}>
                            发送
                        </Button>
                    </Tooltip>
                }
            </div>
        </div>
    );
};

export default ChatApp;
typescript 复制代码
.chat-container {
    display: flex;
    flex-direction: column;
    height: 100vh; /* 高度占满整个屏幕 */
    width: 100%;
    background-color: rgb(230, 230, 230);
    font-family: Arial, sans-serif;
    overflow: auto;

    :global {
        .el-select .el-input__inner {
            height: 27px;
            width: 183px;
            margin-right: 27px;
            font-size: 12px;
            border-color: #e6e6e6;
            border-radius: 0;
        }
    }
}

.chat-box {
    flex: 1; /* chat-box 占用剩余空间 */
    overflow-y: auto; /* 允许滚动 */
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    background-color: #ffffff;
}

.message-wrapper {
    display: flex;
    gap: 10px;
}

.message-wrapper.user {
    flex-direction: row-reverse;
}

.message-wrapper.bot {
    justify-content: flex-start;
}

.message {
    padding: 10px 14px;
    border-radius: 12px;
    background-color: #007bff;
    color: #ffffff;
    word-break: break-word;
    white-space: pre-wrap;
}

.message-wrapper.bot .message {
    background-color: #f1f1f1;
    color: #333333;
}

.search-box {
    position: sticky;
    top: 0; /* 吸顶 */
    margin: 10px;
    margin-left: auto; /* 将下拉框移到右侧 */
    z-index: 9999; /* 确保层级足够高 */
    background-color: #007bff;
    padding: 5px;
    border-radius: 4px;
    display: flex;
    align-items: center;
    justify-content: flex-end; /* 确保内容靠右对齐 */
}

.tip {
    display: flex;
    justify-content: space-between;

    .process {
        color: #999;
        font-size: 16px;
        margin: 10px;
        margin-left: 20px;
    }

    .stop {
        color: #0078d4;
        font-size: 16px;
        margin: 10px;
        cursor: pointer;
        margin-right: 20px;
    }
}

.input-box {
    position: sticky;
    bottom: 0; /* 吸底 */
    display: flex;
    justify-content: space-between;
    padding: 20px;
    background-color: #fff;
    border-top: 1px solid #e0e0e0;
}

.input-box input {
    flex: 1;
    padding: 4px;
    border-radius: 20px;
    border: 1px solid #ccc;
    margin-right: 14px;
    font-size: 14px;
}

.avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    overflow: hidden;
    margin-right: 10px;
}

.avatar img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

:global {
    .el-select-dropdown {
        top: 33px !important;
    }

    .down_select {
        .c7n-tree-treenode {
            .c7n-tree-node-content-wrapper {
                .c7n-tree-title {
                    color: #007bff !important;
                }
            }
        }

        .c7n-tree-treenode-disabled {
            .c7n-tree-node-content-wrapper {
                .c7n-tree-title {
                    color: #d9d9d9 !important;
                }
            }
        }
    }

    .aipage_form {
        .c7n-pro-field-label {
            width: 35px !important;
            color: #333333 !important;
        }
    }
}
相关推荐
Catherinemin3 分钟前
CSS|14 z-index
前端·css
2401_882727572 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder2 小时前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂2 小时前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand2 小时前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL2 小时前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿2 小时前
react防止页面崩溃
前端·react.js·前端框架
z千鑫3 小时前
【前端】详解前端三大主流框架:React、Vue与Angular的比较与选择
前端·vue.js·react.js
m0_748256143 小时前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小白学前端6664 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react