feat(chat): stream reasoning via SSE

main
sp mac bookpro 2605 2026-05-29 20:21:12 +08:00
parent e2855e0e46
commit f7abd99a3e
2 changed files with 134 additions and 24 deletions

View File

@ -95,12 +95,14 @@ export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
reasoning?: string | null;
parentId?: string | null;
createdAt: number;
meta?: {
retrieved?: RetrievedSnippet[];
toolCalls?: ToolCallTrace[];
aborted?: boolean;
reasoning?: string;
} | null;
}
@ -536,8 +538,9 @@ export const LLMProviderAPI = {
export interface StreamEvents {
onMeta?: (data: { userMsgId: string; retrieved: RetrievedSnippet[]; toolsAvailable: number; mcpToolsCount: number }) => void;
onDelta?: (text: string) => void;
onMeta?: (data: any) => void;
onReasoningDelta?: (text: string) => void;
onDelta?: (text: string) => void;
onToolCall?: (data: { id: string; name: string; args: any }) => void;
onToolResult?: (data: { id: string; name: string; result: any }) => void;
onDone?: (data: { user: ChatMessage; assistant: ChatMessage }) => void;
@ -563,7 +566,7 @@ export async function streamChat(
) {
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({
content,
sessionId,
@ -587,7 +590,7 @@ export async function regenerateMessage(
) {
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ overrides, attachmentsText }),
signal,
credentials: 'include'
@ -633,6 +636,9 @@ async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal)
case 'meta':
h.onMeta?.(data);
break;
case 'reasoning_delta':
h.onReasoningDelta?.(data.content || '');
break;
case 'delta':
h.onDelta?.(data.content || '');
break;

View File

@ -25,7 +25,9 @@ import PromptLibraryPage from './PromptLibraryPage';
interface StreamingState {
active: boolean;
text: string;
reasoningText: string;
answerText: string;
errorMessage: string | null;
retrieved: RetrievedSnippet[];
toolCalls: ToolCallTrace[];
}
@ -61,7 +63,9 @@ export default function ChatPage() {
const [uploadingAtt, setUploadingAtt] = useState(false);
const [streaming, setStreaming] = useState<StreamingState>({
active: false,
text: '',
reasoningText: '',
answerText: '',
errorMessage: null,
retrieved: [],
toolCalls: []
});
@ -186,7 +190,14 @@ export default function ChatPage() {
createdAt: Date.now()
};
setMessages((m) => [...(m || []), tempUser]);
setStreaming({ active: true, text: '', retrieved: [], toolCalls: [] });
setStreaming({
active: true,
reasoningText: '',
answerText: '',
errorMessage: null,
retrieved: [],
toolCalls: []
});
scrollBottom();
abortRef.current?.abort();
@ -203,9 +214,15 @@ export default function ChatPage() {
content,
{
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
onReasoningDelta: (chunk) =>
setStreaming((s) => {
const next = { ...s, reasoningText: s.reasoningText + chunk };
scrollBottom();
return next;
}),
onDelta: (chunk) =>
setStreaming((s) => {
const next = { ...s, text: s.text + chunk };
const next = { ...s, answerText: s.answerText + chunk };
scrollBottom();
return next;
}),
@ -226,8 +243,25 @@ export default function ChatPage() {
return { ...s, toolCalls: list };
}),
onDone: (data) => {
setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), data.user, data.assistant]);
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming((s) => {
const assistant: any = data.assistant;
const reasoningText =
assistant?.reasoning ||
assistant?.meta?.reasoning ||
assistant?.meta?.reasoningText ||
s.reasoningText;
const nextAssistant: ChatMessage = {
...data.assistant,
meta: {
...(data.assistant.meta || {}),
retrieved: data.assistant.meta?.retrieved || s.retrieved,
toolCalls: data.assistant.meta?.toolCalls || s.toolCalls,
reasoning: reasoningText || undefined
}
};
setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), data.user, nextAssistant]);
return { active: false, reasoningText: '', answerText: '', errorMessage: null, retrieved: [], toolCalls: [] };
});
setSessionRefresh((t) => t + 1);
setAttachments([]); // 用完即清
setImageUrls([]);
@ -235,12 +269,19 @@ export default function ChatPage() {
},
onAborted: (data) => {
// 已停止保留已生成内容user 消息也留下(用 tempUser 占位)
const assistant: ChatMessage = {
...data.assistant,
meta: {
...(data.assistant.meta || {}),
aborted: true
}
};
setMessages((m) => [
...(m || []).filter((x) => x.id !== tempUser.id),
{ ...tempUser, id: 'u-' + data.assistant.id }, // 占位
data.assistant
assistant
]);
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retrieved: [], toolCalls: [] });
setSessionRefresh((t) => t + 1);
// 重新拉一次确保和后端一致user 真实 id 在那边)
loadMessages();
@ -248,7 +289,7 @@ export default function ChatPage() {
onError: (errMsg) => {
msg.error('流式失败:' + errMsg);
setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id));
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retrieved: [], toolCalls: [] });
}
},
ctrl.signal,
@ -261,7 +302,14 @@ export default function ChatPage() {
msg.error('请求失败:' + (e?.message ?? e));
}
setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id));
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({
active: false,
reasoningText: '',
answerText: '',
errorMessage: e?.message ?? String(e),
retrieved: [],
toolCalls: []
});
}
};
@ -326,7 +374,14 @@ export default function ChatPage() {
const handleRegenerate = async (assistantId: string) => {
if (!id || sending) return;
setSending(true);
setStreaming({ active: true, text: '', retrieved: [], toolCalls: [] });
setStreaming({
active: true,
reasoningText: '',
answerText: '',
errorMessage: null,
retrieved: [],
toolCalls: []
});
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
@ -336,9 +391,15 @@ export default function ChatPage() {
assistantId,
{
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
onReasoningDelta: (chunk) =>
setStreaming((s) => {
const next = { ...s, reasoningText: s.reasoningText + chunk };
scrollBottom();
return next;
}),
onDelta: (chunk) =>
setStreaming((s) => {
const next = { ...s, text: s.text + chunk };
const next = { ...s, answerText: s.answerText + chunk };
scrollBottom();
return next;
}),
@ -359,16 +420,16 @@ export default function ChatPage() {
return { ...s, toolCalls: list };
}),
onDone: () => {
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retrieved: [], toolCalls: [] });
loadMessages();
},
onAborted: () => {
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retrieved: [], toolCalls: [] });
loadMessages();
},
onError: (errMsg) => {
msg.error('重新生成失败:' + errMsg);
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retrieved: [], toolCalls: [] });
loadMessages();
}
},
@ -579,11 +640,29 @@ export default function ChatPage() {
{streaming.active && (
<div style={{ marginBottom: 24 }}>
<div className="bubble assistant">
{streaming.text ? (
<ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown>
) : (
<span style={{ color: 'var(--color-text-tertiary)' }}></span>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>
</div>
{streaming.reasoningText ? (
<ReactMarkdown>{streaming.reasoningText + '▍'}</ReactMarkdown>
) : (
<span style={{ color: 'var(--color-text-tertiary)' }}></span>
)}
</div>
<Divider style={{ margin: '6px 0' }} />
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>
</div>
{streaming.answerText ? (
<ReactMarkdown>{streaming.answerText + '▍'}</ReactMarkdown>
) : (
<span style={{ color: 'var(--color-text-tertiary)' }}></span>
)}
</div>
</div>
</div>
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
<div style={{ maxWidth: '85%' }}>
@ -1069,6 +1148,7 @@ function MessageItem({
)}
{message.role === 'assistant' && message.meta && (
<div style={{ maxWidth: '78%' }}>
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
</div>
@ -1077,6 +1157,30 @@ function MessageItem({
);
}
function ReasoningView({ reasoning }: { reasoning: string }) {
return (
<Collapse
size="small"
ghost
items={[
{
key: 'reasoning',
label: (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
🧠
</span>
),
children: (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
<ReactMarkdown>{reasoning}</ReactMarkdown>
</div>
)
}
]}
/>
);
}
function RetrievedView({ retrieved, liveStyle }: { retrieved: RetrievedSnippet[]; liveStyle?: boolean }) {
return (
<Collapse