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(),
};
}