feat(chat): stream reasoning via SSE
parent
e2855e0e46
commit
f7abd99a3e
12
src/api.ts
12
src/api.ts
|
|
@ -95,12 +95,14 @@ export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
|
reasoning?: string | null;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
meta?: {
|
meta?: {
|
||||||
retrieved?: RetrievedSnippet[];
|
retrieved?: RetrievedSnippet[];
|
||||||
toolCalls?: ToolCallTrace[];
|
toolCalls?: ToolCallTrace[];
|
||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
|
reasoning?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -536,7 +538,8 @@ export const LLMProviderAPI = {
|
||||||
|
|
||||||
|
|
||||||
export interface StreamEvents {
|
export interface StreamEvents {
|
||||||
onMeta?: (data: { userMsgId: string; retrieved: RetrievedSnippet[]; toolsAvailable: number; mcpToolsCount: number }) => void;
|
onMeta?: (data: any) => void;
|
||||||
|
onReasoningDelta?: (text: string) => void;
|
||||||
onDelta?: (text: string) => void;
|
onDelta?: (text: string) => void;
|
||||||
onToolCall?: (data: { id: string; name: string; args: any }) => void;
|
onToolCall?: (data: { id: string; name: string; args: any }) => void;
|
||||||
onToolResult?: (data: { id: string; name: string; result: any }) => void;
|
onToolResult?: (data: { id: string; name: string; result: any }) => void;
|
||||||
|
|
@ -563,7 +566,7 @@ export async function streamChat(
|
||||||
) {
|
) {
|
||||||
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
|
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content,
|
content,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|
@ -587,7 +590,7 @@ export async function regenerateMessage(
|
||||||
) {
|
) {
|
||||||
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, {
|
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||||
body: JSON.stringify({ overrides, attachmentsText }),
|
body: JSON.stringify({ overrides, attachmentsText }),
|
||||||
signal,
|
signal,
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
|
@ -633,6 +636,9 @@ async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal)
|
||||||
case 'meta':
|
case 'meta':
|
||||||
h.onMeta?.(data);
|
h.onMeta?.(data);
|
||||||
break;
|
break;
|
||||||
|
case 'reasoning_delta':
|
||||||
|
h.onReasoningDelta?.(data.content || '');
|
||||||
|
break;
|
||||||
case 'delta':
|
case 'delta':
|
||||||
h.onDelta?.(data.content || '');
|
h.onDelta?.(data.content || '');
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ import PromptLibraryPage from './PromptLibraryPage';
|
||||||
|
|
||||||
interface StreamingState {
|
interface StreamingState {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
text: string;
|
reasoningText: string;
|
||||||
|
answerText: string;
|
||||||
|
errorMessage: string | null;
|
||||||
retrieved: RetrievedSnippet[];
|
retrieved: RetrievedSnippet[];
|
||||||
toolCalls: ToolCallTrace[];
|
toolCalls: ToolCallTrace[];
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +63,9 @@ export default function ChatPage() {
|
||||||
const [uploadingAtt, setUploadingAtt] = useState(false);
|
const [uploadingAtt, setUploadingAtt] = useState(false);
|
||||||
const [streaming, setStreaming] = useState<StreamingState>({
|
const [streaming, setStreaming] = useState<StreamingState>({
|
||||||
active: false,
|
active: false,
|
||||||
text: '',
|
reasoningText: '',
|
||||||
|
answerText: '',
|
||||||
|
errorMessage: null,
|
||||||
retrieved: [],
|
retrieved: [],
|
||||||
toolCalls: []
|
toolCalls: []
|
||||||
});
|
});
|
||||||
|
|
@ -186,7 +190,14 @@ export default function ChatPage() {
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
setMessages((m) => [...(m || []), tempUser]);
|
setMessages((m) => [...(m || []), tempUser]);
|
||||||
setStreaming({ active: true, text: '', retrieved: [], toolCalls: [] });
|
setStreaming({
|
||||||
|
active: true,
|
||||||
|
reasoningText: '',
|
||||||
|
answerText: '',
|
||||||
|
errorMessage: null,
|
||||||
|
retrieved: [],
|
||||||
|
toolCalls: []
|
||||||
|
});
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
|
@ -203,9 +214,15 @@ export default function ChatPage() {
|
||||||
content,
|
content,
|
||||||
{
|
{
|
||||||
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
|
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
|
||||||
|
onReasoningDelta: (chunk) =>
|
||||||
|
setStreaming((s) => {
|
||||||
|
const next = { ...s, reasoningText: s.reasoningText + chunk };
|
||||||
|
scrollBottom();
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
onDelta: (chunk) =>
|
onDelta: (chunk) =>
|
||||||
setStreaming((s) => {
|
setStreaming((s) => {
|
||||||
const next = { ...s, text: s.text + chunk };
|
const next = { ...s, answerText: s.answerText + chunk };
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
return next;
|
return next;
|
||||||
}),
|
}),
|
||||||
|
|
@ -226,8 +243,25 @@ export default function ChatPage() {
|
||||||
return { ...s, toolCalls: list };
|
return { ...s, toolCalls: list };
|
||||||
}),
|
}),
|
||||||
onDone: (data) => {
|
onDone: (data) => {
|
||||||
setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), data.user, data.assistant]);
|
setStreaming((s) => {
|
||||||
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
|
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);
|
setSessionRefresh((t) => t + 1);
|
||||||
setAttachments([]); // 用完即清
|
setAttachments([]); // 用完即清
|
||||||
setImageUrls([]);
|
setImageUrls([]);
|
||||||
|
|
@ -235,12 +269,19 @@ export default function ChatPage() {
|
||||||
},
|
},
|
||||||
onAborted: (data) => {
|
onAborted: (data) => {
|
||||||
// 已停止:保留已生成内容;user 消息也留下(用 tempUser 占位)
|
// 已停止:保留已生成内容;user 消息也留下(用 tempUser 占位)
|
||||||
|
const assistant: ChatMessage = {
|
||||||
|
...data.assistant,
|
||||||
|
meta: {
|
||||||
|
...(data.assistant.meta || {}),
|
||||||
|
aborted: true
|
||||||
|
}
|
||||||
|
};
|
||||||
setMessages((m) => [
|
setMessages((m) => [
|
||||||
...(m || []).filter((x) => x.id !== tempUser.id),
|
...(m || []).filter((x) => x.id !== tempUser.id),
|
||||||
{ ...tempUser, id: 'u-' + data.assistant.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);
|
setSessionRefresh((t) => t + 1);
|
||||||
// 重新拉一次确保和后端一致(user 真实 id 在那边)
|
// 重新拉一次确保和后端一致(user 真实 id 在那边)
|
||||||
loadMessages();
|
loadMessages();
|
||||||
|
|
@ -248,7 +289,7 @@ export default function ChatPage() {
|
||||||
onError: (errMsg) => {
|
onError: (errMsg) => {
|
||||||
msg.error('流式失败:' + errMsg);
|
msg.error('流式失败:' + errMsg);
|
||||||
setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id));
|
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,
|
ctrl.signal,
|
||||||
|
|
@ -261,7 +302,14 @@ export default function ChatPage() {
|
||||||
msg.error('请求失败:' + (e?.message ?? e));
|
msg.error('请求失败:' + (e?.message ?? e));
|
||||||
}
|
}
|
||||||
setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id));
|
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) => {
|
const handleRegenerate = async (assistantId: string) => {
|
||||||
if (!id || sending) return;
|
if (!id || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setStreaming({ active: true, text: '', retrieved: [], toolCalls: [] });
|
setStreaming({
|
||||||
|
active: true,
|
||||||
|
reasoningText: '',
|
||||||
|
answerText: '',
|
||||||
|
errorMessage: null,
|
||||||
|
retrieved: [],
|
||||||
|
toolCalls: []
|
||||||
|
});
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
|
|
@ -336,9 +391,15 @@ export default function ChatPage() {
|
||||||
assistantId,
|
assistantId,
|
||||||
{
|
{
|
||||||
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
|
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
|
||||||
|
onReasoningDelta: (chunk) =>
|
||||||
|
setStreaming((s) => {
|
||||||
|
const next = { ...s, reasoningText: s.reasoningText + chunk };
|
||||||
|
scrollBottom();
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
onDelta: (chunk) =>
|
onDelta: (chunk) =>
|
||||||
setStreaming((s) => {
|
setStreaming((s) => {
|
||||||
const next = { ...s, text: s.text + chunk };
|
const next = { ...s, answerText: s.answerText + chunk };
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
return next;
|
return next;
|
||||||
}),
|
}),
|
||||||
|
|
@ -359,16 +420,16 @@ export default function ChatPage() {
|
||||||
return { ...s, toolCalls: list };
|
return { ...s, toolCalls: list };
|
||||||
}),
|
}),
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retrieved: [], toolCalls: [] });
|
||||||
loadMessages();
|
loadMessages();
|
||||||
},
|
},
|
||||||
onAborted: () => {
|
onAborted: () => {
|
||||||
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retrieved: [], toolCalls: [] });
|
||||||
loadMessages();
|
loadMessages();
|
||||||
},
|
},
|
||||||
onError: (errMsg) => {
|
onError: (errMsg) => {
|
||||||
msg.error('重新生成失败:' + errMsg);
|
msg.error('重新生成失败:' + errMsg);
|
||||||
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retrieved: [], toolCalls: [] });
|
||||||
loadMessages();
|
loadMessages();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -579,12 +640,30 @@ export default function ChatPage() {
|
||||||
{streaming.active && (
|
{streaming.active && (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div className="bubble assistant">
|
<div className="bubble assistant">
|
||||||
{streaming.text ? (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown>
|
<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>
|
<span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) && (
|
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||||||
<div style={{ maxWidth: '85%' }}>
|
<div style={{ maxWidth: '85%' }}>
|
||||||
{streaming.retrieved.length > 0 && (
|
{streaming.retrieved.length > 0 && (
|
||||||
|
|
@ -1069,6 +1148,7 @@ function MessageItem({
|
||||||
)}
|
)}
|
||||||
{message.role === 'assistant' && message.meta && (
|
{message.role === 'assistant' && message.meta && (
|
||||||
<div style={{ maxWidth: '78%' }}>
|
<div style={{ maxWidth: '78%' }}>
|
||||||
|
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
||||||
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
||||||
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
||||||
</div>
|
</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 }) {
|
function RetrievedView({ retrieved, liveStyle }: { retrieved: RetrievedSnippet[]; liveStyle?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue