feat: AIOps智能助手流式输出(SSE代理,逐字渲染+思考中光标+断连安全持久化)

This commit is contained in:
attacker committed 2026-06-18 05:07:28 +08:00
1 parent 139bddc9d9
commit 5d19878b28
5 files changed
+362 -31

No files matched your search

@@ -234,7 +234,7 @@ export function AIOpsAssistant() {
}
};
// 发送一条消息的核心逻辑(供输入框发送和快捷操作复用) // Logan.Li
// 发送一条消息的核心逻辑(流式输出,逐步填充助手气泡) // Logan.Li
const sendMessage = async (text: string) => {
const userMsg: Message = {
id: Date.now().toString(),
@@ -242,23 +242,44 @@ export function AIOpsAssistant() {
content: text,
timestamp: new Date().toISOString(),
};
const newMessages = [...messages, userMsg];
const assistantId = (Date.now() + 1).toString();
const newMessages: Message[] = [
...messages,
userMsg,
// 预先插入空助手消息,随流式内容逐步填充
{ id: assistantId, role: 'assistant', content: '', timestamp: new Date().toISOString() },
];
setMessages(newMessages);
setIsLoading(true);
// 用于在收到第一个 chunk 前显示"思考中"占位
let firstChunkArrived = false;
try {
// 仅保留最近 10 条上下文避免 token 超限 // Logan.Li
const history = newMessages.slice(-10).map((m) => ({ role: m.role, content: m.content }));
const result = await aiApi.chat(history);
setMessages((prev) => [
...prev,
{ id: (Date.now() + 1).toString(), role: 'assistant', content: result.content, timestamp: new Date().toISOString() },
]);
// 仅保留最近 10 条上下文避免 token 超限(不含刚插入的空助手消息) // Logan.Li
const history = [...messages, userMsg]
.slice(-10)
.map((m) => ({ role: m.role, content: m.content }));
await aiApi.chatStream(history, (delta) => {
firstChunkArrived = true;
// 逐块追加到助手消息内容
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content + delta } : m,
),
);
});
} catch (e) {
const errMsg = e instanceof Error ? e.message : '对话失败';
setMessages((prev) => [
...prev,
{ id: (Date.now() + 1).toString(), role: 'assistant', content: `⚠️ ${errMsg}`, timestamp: new Date().toISOString(), isError: true },
]);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: firstChunkArrived ? m.content + `\n\n⚠️ ${errMsg}` : `⚠️ ${errMsg}`,
isError: !firstChunkArrived,
}
: m,
),
);
} finally {
setIsLoading(false);
}
@@ -431,7 +452,10 @@ export function AIOpsAssistant() {
)}
</div>
)}
{messages.map((message) => (
{messages.map((message) => {
// 流式中:助手消息内容为空且正在加载时显示"思考中"占位 // Logan.Li
const isStreaming = isLoading && message.role === 'assistant' && message.content === '';
return (
<div key={message.id} className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}>
<Avatar className={message.role === 'assistant' ? 'bg-purple-500' : 'bg-blue-500'}>
<AvatarFallback>
@@ -448,27 +472,25 @@ export function AIOpsAssistant() {
: 'bg-blue-600 text-white'
}`}
>
{message.content}
{isStreaming ? (
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">思考中...</span>
</div>
) : (
message.content
)}
{/* 流式接收中且已有内容时,显示末尾闪烁光标提示 */}
{isLoading && message.role === 'assistant' && !isStreaming && message.content && (
<span className="inline-block w-2 h-4 ml-0.5 bg-purple-400 animate-pulse align-middle" />
)}
</div>
<div className="text-xs text-slate-500 mt-1">{fmtTime(message.timestamp)}</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<Avatar className="bg-purple-500">
<AvatarFallback>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="bg-slate-700 rounded-lg p-3">
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">思考中...</span>
</div>
</div>
</div>
)}
);
})}
{/* 流式输出时占位气泡已包含"思考中"提示,无需独立 spinner */}
<div ref={messagesEndRef} />
</div>
</ScrollArea>