feat(chat): stream reasoning via SSE
parent
e2855e0e46
commit
f7abd99a3e
14
src/api.ts
14
src/api.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue