chore(align): sync Git with running container as authoritative source
Phase 0 SSE 流式聊天 landed in the running container and captured in Git so
the three sources (container := image := Git) agree.
Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎):
- app/api/chat.py: 新增独立 SSE 端点
POST /app/api/chat/conversations/{conv_id}/messages/stream
基于 asyncio.Queue + create_task 将 _send_message_core 桥接为
text/event-stream 生成器,吐出 delta / reasoning / done / error 事件
- app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数;
普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式;
done 事件携带 message_id / content / process_steps / used_skills /
used_mcp / generating
- app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug
(流式调用传 stream=True 导致 TypeError: multiple values for keyword),
现 _create_kwargs(request_body, stream=True) 字段随参数变化
- frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts:
发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning),
done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式
Alignment performed 2026-07-24 16:28:19. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
e8a1791c9f
commit
3788a93425
6 files changed
+11764
-5862
No files matched your search
@@ -1759,3 +1759,23 @@ html.dark .ai-sending-timeout {
|
||||
.ai-cancel-btn:hover {
|
||||
background: color-mix(in srgb, #e74c3c 15%, var(--color-bg-tertiary)) !important;
|
||||
}
|
||||
|
||||
/* ===== Phase 0 SSE 流式:思考链实时渲染 ===== */
|
||||
/* 思考链文本保留模型原始换行(deepseek 原生 reasoning 含多段换行) */
|
||||
.ai-process-thinking .ai-process-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 流式生成中提示 */
|
||||
.streaming-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.streaming-hint svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -559,6 +559,8 @@ const ChatPage: React.FC = () => {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendingTimeout, setSendingTimeout] = useState(false);
|
||||
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
|
||||
const [streamingId, setStreamingId] = useState<string | null>(null);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(true);
|
||||
@@ -933,7 +935,162 @@ const ChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle send message - CALLS REAL API
|
||||
// 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退
|
||||
const sendLegacy = async (
|
||||
payload: any,
|
||||
tempUserMsg: Message,
|
||||
) => {
|
||||
const res = await chatAPI.sendMessage(selectedConversation!.id, payload);
|
||||
const data = unwrapData(res);
|
||||
|
||||
// Replace temporary user message and add assistant response
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => m.id !== tempUserMsg.id);
|
||||
const newMsgs: Message[] = [];
|
||||
if (data.user_message) {
|
||||
newMsgs.push({
|
||||
id: data.user_message.id,
|
||||
conversationId: selectedConversation?.id || '',
|
||||
role: 'user',
|
||||
content: data.user_message.content,
|
||||
tokenUsed: data.user_message.token_used || 0,
|
||||
createdAt: data.user_message.created_at || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (data.assistant_message) {
|
||||
newMsgs.push({
|
||||
id: data.assistant_message.id,
|
||||
conversationId: selectedConversation?.id || '',
|
||||
role: 'assistant',
|
||||
content: data.assistant_message.content,
|
||||
tokenUsed: data.assistant_message.token_used || 0,
|
||||
createdAt: data.assistant_message.created_at || new Date().toISOString(),
|
||||
used_skills: data.assistant_message.used_skills || [],
|
||||
used_mcp: data.assistant_message.used_mcp || [],
|
||||
process_steps: data.assistant_message.process_steps || [],
|
||||
});
|
||||
}
|
||||
return [...filtered, ...newMsgs];
|
||||
});
|
||||
|
||||
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
|
||||
if (data.assistant_message) {
|
||||
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
|
||||
}
|
||||
|
||||
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
||||
if (data.assistant_message?.generating) {
|
||||
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
||||
}
|
||||
|
||||
// Refresh conversations to update lastMessage
|
||||
loadConversations();
|
||||
// 发送后清空本次附件(选择状态仍保留)
|
||||
setAttachments([]);
|
||||
|
||||
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
|
||||
if (selectedConversation?.id && data.assistant_message) {
|
||||
const recentMsgs = [
|
||||
...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
|
||||
{ role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
|
||||
];
|
||||
evolutionAPI.submitTrajectory({
|
||||
conversation_id: selectedConversation.id,
|
||||
messages: recentMsgs,
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning)
|
||||
const streamSend = async (
|
||||
convId: string,
|
||||
payload: any,
|
||||
tempUserMsg: Message,
|
||||
assistantId: string,
|
||||
): Promise<void> => {
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(`SSE 连接失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let accReasoning = '';
|
||||
let gotDone = false;
|
||||
|
||||
const patchAssistant = (patch: Partial<Message>) => {
|
||||
setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, ...patch } : m)));
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
||||
const raw = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const dataLine = raw.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
let evt: any;
|
||||
try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
||||
if (evt.type === 'delta') {
|
||||
const piece = evt.content || '';
|
||||
setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, content: (m.content || '') + piece } : m)));
|
||||
} else if (evt.type === 'reasoning') {
|
||||
accReasoning += evt.content || '';
|
||||
patchAssistant({ reasoning: accReasoning } as any);
|
||||
} else if (evt.type === 'done') {
|
||||
gotDone = true;
|
||||
const finalId = evt.message_id || assistantId;
|
||||
setMessages(prev => prev.map(m => {
|
||||
if (m.id !== assistantId) return m;
|
||||
return {
|
||||
...m,
|
||||
id: finalId,
|
||||
content: evt.content ?? m.content,
|
||||
process_steps: evt.process_steps || [],
|
||||
used_skills: evt.used_skills || [],
|
||||
used_mcp: evt.used_mcp || [],
|
||||
generating: !!evt.generating,
|
||||
};
|
||||
}));
|
||||
setStreamingId(null);
|
||||
if (evt.generating) startMediaPolling(convId, finalId);
|
||||
setFreshMsgIds(prev => new Set(prev).add(finalId));
|
||||
loadConversations();
|
||||
setAttachments([]);
|
||||
if (convId && evt.content) {
|
||||
evolutionAPI.submitTrajectory({
|
||||
conversation_id: convId,
|
||||
messages: [
|
||||
{ role: 'user', content: tempUserMsg.content, tokens: 0 },
|
||||
{ role: 'assistant', content: evt.content, tokens: 0 },
|
||||
],
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (evt.type === 'error') {
|
||||
throw new Error(evt.message || '生成失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!gotDone) {
|
||||
// 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
|
||||
setStreamingId(null);
|
||||
throw new Error('流式响应未正常结束');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式)
|
||||
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
|
||||
const sendText = async (content: string) => {
|
||||
if (!content.trim() || sending || !selectedConversation) return;
|
||||
@@ -956,83 +1113,44 @@ const ChatPage: React.FC = () => {
|
||||
};
|
||||
setMessages(prev => [...prev, tempUserMsg]);
|
||||
|
||||
const payload = {
|
||||
content,
|
||||
model_id: selectedModelId || undefined,
|
||||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
||||
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
|
||||
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
|
||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||
name: a.name, file_type: a.file_type, content: a.content || '',
|
||||
})) : undefined,
|
||||
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
|
||||
};
|
||||
|
||||
// 先放一个空的助手消息占位,用于 SSE 流式增量渲染
|
||||
const assistantId = `temp_assistant_${Date.now()}`;
|
||||
setMessages(prev => [...prev, {
|
||||
id: assistantId,
|
||||
conversationId: selectedConversation!.id,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tokenUsed: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
reasoning: '',
|
||||
} as Message]);
|
||||
setStreamingId(assistantId);
|
||||
|
||||
try {
|
||||
const res = await chatAPI.sendMessage(selectedConversation!.id, {
|
||||
content,
|
||||
model_id: selectedModelId || undefined,
|
||||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
||||
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
|
||||
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
|
||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||
name: a.name, file_type: a.file_type, content: a.content || '',
|
||||
})) : undefined,
|
||||
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
|
||||
});
|
||||
|
||||
const data = unwrapData(res);
|
||||
|
||||
// Replace temporary user message and add assistant response
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => m.id !== tempUserMsg.id);
|
||||
const newMsgs: Message[] = [];
|
||||
if (data.user_message) {
|
||||
newMsgs.push({
|
||||
id: data.user_message.id,
|
||||
conversationId: selectedConversation?.id || '',
|
||||
role: 'user',
|
||||
content: data.user_message.content,
|
||||
tokenUsed: data.user_message.token_used || 0,
|
||||
createdAt: data.user_message.created_at || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (data.assistant_message) {
|
||||
newMsgs.push({
|
||||
id: data.assistant_message.id,
|
||||
conversationId: selectedConversation?.id || '',
|
||||
role: 'assistant',
|
||||
content: data.assistant_message.content,
|
||||
tokenUsed: data.assistant_message.token_used || 0,
|
||||
createdAt: data.assistant_message.created_at || new Date().toISOString(),
|
||||
used_skills: data.assistant_message.used_skills || [],
|
||||
used_mcp: data.assistant_message.used_mcp || [],
|
||||
process_steps: data.assistant_message.process_steps || [],
|
||||
});
|
||||
}
|
||||
return [...filtered, ...newMsgs];
|
||||
});
|
||||
|
||||
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
|
||||
if (data.assistant_message) {
|
||||
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
|
||||
}
|
||||
|
||||
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
||||
if (data.assistant_message?.generating) {
|
||||
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
||||
}
|
||||
|
||||
// Refresh conversations to update lastMessage
|
||||
loadConversations();
|
||||
// 发送后清空本次附件(选择状态仍保留)
|
||||
setAttachments([]);
|
||||
|
||||
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
|
||||
if (selectedConversation?.id && data.assistant_message) {
|
||||
const recentMsgs = [
|
||||
...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
|
||||
{ role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
|
||||
];
|
||||
evolutionAPI.submitTrajectory({
|
||||
conversation_id: selectedConversation.id,
|
||||
messages: recentMsgs,
|
||||
}).catch(() => {});
|
||||
}
|
||||
await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
|
||||
} catch (err: any) {
|
||||
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
|
||||
// Remove optimistic message on error
|
||||
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
|
||||
alert('发送失败: ' + (err.message || err.detail || '未知错误'));
|
||||
// 移除流式占位,回退非流式重新发送
|
||||
setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id));
|
||||
setStreamingId(null);
|
||||
try {
|
||||
await sendLegacy(payload, tempUserMsg);
|
||||
} catch (e2: any) {
|
||||
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
|
||||
alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
|
||||
setSending(false);
|
||||
@@ -1286,6 +1404,22 @@ const ChatPage: React.FC = () => {
|
||||
{msg.role === 'assistant' && steps.length > 0 && (
|
||||
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
||||
)}
|
||||
{msg.role === 'assistant' && (msg as any).reasoning && (
|
||||
<details className="ai-process ai-process-reasoning" open>
|
||||
<summary className="ai-process-summary">
|
||||
<span className="ai-process-badge">🤔</span>
|
||||
AI 思考过程(实时)
|
||||
</summary>
|
||||
<div className="ai-process-list">
|
||||
<div className="ai-process-item ai-process-thinking">
|
||||
<span className="ai-process-ico">💡</span>
|
||||
<div className="ai-process-body">
|
||||
<p className="ai-process-text">{(msg as any).reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<div className={`message-bubble ${msg.role}`}>
|
||||
{clarify ? (
|
||||
<ClarifyCard
|
||||
@@ -1306,7 +1440,7 @@ const ChatPage: React.FC = () => {
|
||||
}}>🔄 重试</button>
|
||||
</div>
|
||||
) : msg.role === 'assistant' ? (
|
||||
<MarkdownContent content={msg.content} />
|
||||
msg.content ? <MarkdownContent content={msg.content} /> : (streamingId === msg.id ? <span className="streaming-hint"><IconSpinner /> 生成中…</span> : null)
|
||||
) : (
|
||||
<span>{msg.content}</span>
|
||||
)}
|
||||
@@ -1370,7 +1504,7 @@ const ChatPage: React.FC = () => {
|
||||
);
|
||||
})
|
||||
)}
|
||||
{sending && (
|
||||
{sending && !streamingId && (
|
||||
<div className="message-row assistant">
|
||||
<div className={`message-bubble assistant${sendingTimeout ? ' ai-sending-timeout' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<IconSpinner />
|
||||
|
||||
Reference in new issue
Block a user