ChatUI使用SSE流式响应显示输出内容

ChatUI是达摩院阿里小蜜孵化的对话式界面组件,能够促进快速搭建机器人对话界面。

首先使用npm安装microsoft/fetch-event-source
然后导入fetchEventSource, EventStreamContentType
import { fetchEventSource, EventStreamContentType } from '@microsoft/fetch-event-source';

使用sse显示响应重点在于Chat组件的onSend事件和renderMessageContent事件处理函数中

return (
    <Chat
        navbar={{ title: 'My AI Assistant' }}
        messages={messages}
        renderMessageContent={renderMessageContent}
        //quickReplies={defaultQuickReplies}
        onQuickReplyClick={handleQuickReplyClick}
        onSend={handleSend}
        inputOptions={{ disabled: sending }}
    />
);

定义sending变量,用来控制输入框的禁用。当用户点击发送按钮后禁用输入框,待流式响应返回完毕或者返回错误再启用输入框。
const [sending, setSending] = useState(false);

onSend事件

onSend事件处理函数handleSend是当用户点击发送按钮时调用的方法。主要作用是将用户发送的消息添加到对话框和发起sse api请求。

async function handleSend(type, val) {
    if (type === 'text' && val.trim()) {
        setSending(true);//点击发送按钮后禁用输入框
        appendMsg({
            type: 'text',
            content: { text: val },
            user: {
                avatar: userImg,
            },
            position: 'right',
            status: 'pending',
        });
        let streamMsgId = '';
        streamMsgId = generateGuid(); // 生成流式消息ID  自己实现,也可以从api中获取
        appendMsg({
            _id: streamMsgId,
            type: 'typing',
            content: { text: '' },
            user: {
                avatar: assistantImg,
            },
        });
        return new Promise((resolve) => {
            let msg = '';
            fetchEventSource('/api/chat/complete', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                openWhenHidden: true,
                body: JSON.stringify({
                    Message: val
                }),
                async onopen(response) {
                    if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
                        return; // everything's good
                    } else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
                        setSending(false);//返回错误  启用输入框
                        // client-side errors are usually non-retriable:
                        updateMsg(streamMsgId, {
                            type: 'text',
                            content: { text: 'Sorry, I am unable to process your request at this time. Please try again later.' },
                        })
                        //throw new FatalError();
                    } else {
                        throw new RetriableError();
                    }
                },
                onmessage: (event) => {
                    if (event.event === 'FatalError') {
                        setSending(false);//返回错误  启用输入框
                        throw new FatalError(event.data);
                    }
                    msg += event.data;
                    updateMsg(streamMsgId, {
                        type: 'stream',
                        content: { text: msg },
                        user: {
                            avatar: assistantImg,
                        },
                    });
                },
                onclose() {
                    // if the server closes the connection unexpectedly, retry:
                    //throw new RetriableError();
                    setSending(false);//流式响应返回完毕  启用输入框
                },
                onerror(err) {
                    setSending(false);//返回错误  启用输入框
                    if (err instanceof FatalError) {
                        updateMsg(streamMsgId, {
                            type: 'stream',
                            content: { text: msg },
                            user: {
                                avatar: assistantImg,
                            },
                            status: 'fail',
                        });
                        throw err; // rethrow to stop the operation
                    } else {
                        // do nothing to automatically retry. You can also
                        // return a specific retry interval here.
                    }
                }
            })
        });
    }
}

renderMessageContent事件处理

renderMessageContent事件处理函数作用是根据消息类型来选择返回不同的消息组件

text --> 使用Bubble直接渲染
stream --> 使用TypingBubble渲染
typing -->使用Typing渲染

const renderMarkdown = (content: string) => marked.parse(content) as string;

function renderMessageContent(msg: MessageProps) {
    const { type, content } = msg;
    // 根据消息类型来渲染
    switch (type) {
        case 'text':
            return (
                <div>
                    <MessageStatus status={content.status} />
                    <Bubble data-animation='fadeInUp' content={content.text} />
                </div>
            );
        case 'stream':
            return (
                <TypingBubble
                    data-animation='fadeInUp'
                    content={content.text}
                    messageRender={renderMarkdown}
                    isRichText
                    options={{ step: [2, 10], interval: 100 }}
                />
            );
        case 'typing':
            return <Typing />;
        default:
            return null;
    }
}

其中的marked是用来将markdown转为html富文本格式,然后再让TypingBubble使用富文本渲染。
需要使用npm安装marked包,然后再导入

import { marked } from 'marked';

后端api实现

我的后端是使用的.net 10 minimalapi实现的。通过前端传入的sessionId来获取会话历史消息,然后拼接传入openai的Chat方法中

app.MapPost("/api/chat/complete", async Task<Results<ServerSentEventsResult<string>, BadRequest<string>>>(ChatRequest request, [FromServices] ChatClient client, [FromServices] IDistributedCache cache, CancellationToken cancellationToken) =>
{
    var key = $"myChat-{request.SessionId}";
    var s = await cache.GetStringAsync(key, cancellationToken);
    if (string.IsNullOrWhiteSpace(s)) return TypedResults.BadRequest("invalid request");
    List<ChatMessageCacheDto> history = JsonSerializer.Deserialize<List<ChatMessageCacheDto>>(s, jsonSerializerOptions) ?? [];
    history.Add(new("user", request.Message));
    async IAsyncEnumerable<string> ChatCompleteAsync(ChatRequest request, ChatClient client, IDistributedCache cache, [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var r = client.CompleteChatStreamingAsync(history.Select(h => h.ToChatMessage()), cancellationToken: cancellationToken);
        StringBuilder sb = new();
        await foreach (var completionUpdate in r)
        {
            if (completionUpdate.ContentUpdate.Count > 0)
            {
                var text = completionUpdate.ContentUpdate[0].Text;
                sb.Append(text);
                if (completionUpdate.FinishReason is not null)
                {
                    history.Add(new("assistant", sb.ToString()));
                        await cache.SetStringAsync(key, JsonSerializer.Serialize(history, jsonSerializerOptions), new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(2) }, cancellationToken);
                }
                yield return text;
            }
        }
    }
    ;
    return TypedResults.ServerSentEvents(ChatCompleteAsync(request, client, cache, cancellationToken));
});


[Description("聊天消息")]
public record ChatRequest([Description("会话id")]string SessionId, [Description("用户消息")] string Message);

public record ChatMessageCacheDto(string Role, string Message)
{
    public ChatMessage ToChatMessage() => Role switch
    {
        "system" => ChatMessage.CreateSystemMessage(Message),
        "assistant" => ChatMessage.CreateAssistantMessage(Message),
        "user" => ChatMessage.CreateUserMessage(Message),
        _ => throw new NotImplementedException(),
    };
}
posted @ 2025-05-19 10:08  turingguo  阅读(380)  评论(0)    收藏  举报