feat: AIOps智能助手流式输出(SSE代理,逐字渲染+思考中光标+断连安全持久化)
This commit is contained in:
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>
|
||||
|
||||
@@ -673,6 +673,78 @@ export const aiApi = {
|
||||
// AI 对话(messages 含历史 + 当前用户消息)
|
||||
chat: (messages: { role: 'user' | 'assistant'; content: string }[]) =>
|
||||
http.post<ChatResult>('/aiops/chat', { messages }),
|
||||
// AI 流式对话(SSE)。逐块回调 onChunk,返回完整内容。错误抛出 Error。
|
||||
// 使用裸 fetch 绕过 http.ts(后者会 await json() 一次性消费) // Logan.Li
|
||||
chatStream: async (
|
||||
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||
onChunk: (delta: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const token = useUserStore.getState().token;
|
||||
const res = await fetch('/api/v1/aiops/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages }),
|
||||
signal,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
useUserStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
}
|
||||
if (!res.body) throw new Error('流式响应不可用');
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let fullContent = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// SSE 事件之间以 \n\n 分隔
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events.pop() ?? '';
|
||||
for (const evt of events) {
|
||||
const lines = evt.split('\n');
|
||||
let eventType = 'message';
|
||||
let dataLines: string[] = [];
|
||||
for (const ln of lines) {
|
||||
if (ln.startsWith('event:')) eventType = ln.slice(6).trim();
|
||||
else if (ln.startsWith('data:')) dataLines.push(ln.slice(5).trim());
|
||||
}
|
||||
const data = dataLines.join('');
|
||||
if (!data) continue;
|
||||
if (eventType === 'error') {
|
||||
// {code, message}
|
||||
try {
|
||||
const obj = JSON.parse(data);
|
||||
throw new Error(obj.message || 'LLM 流式响应错误');
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof Error && parseErr.message) throw parseErr;
|
||||
throw new Error('LLM 流式响应错误');
|
||||
}
|
||||
}
|
||||
if (eventType === 'done') {
|
||||
// 服务端结束(可能含 model 字段)
|
||||
continue;
|
||||
}
|
||||
// message:data 是 {"content":"..."}
|
||||
try {
|
||||
const obj = JSON.parse(data);
|
||||
if (typeof obj.content === 'string') {
|
||||
fullContent += obj.content;
|
||||
onChunk(obj.content);
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 数据忽略(如 [DONE] 或注释)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fullContent;
|
||||
},
|
||||
// 获取当前用户对话历史(按时间正序,最近 100 条)
|
||||
getHistory: () => http.get<{ list: ChatHistoryItem[]; total: number }>('/aiops/chat-history'),
|
||||
// 清空当前用户对话历史
|
||||
|
||||
Reference in new issue
Block a user