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:
QihuAI Design committed 2026-07-24 16:28:19 +08:00
1 parent e8a1791c9f
commit 3788a93425
6 files changed
+11764 -5862

No files matched your search

+20
View File
@@ -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;
}
+212 -78
View File
@@ -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 />
+1
View File
@@ -60,6 +60,7 @@ export interface Message {
content: string;
tokenUsed: number;
createdAt: string;
reasoning?: string;
used_skills?: string[];
used_mcp?: string[];
process_steps?: Array<{