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
+5948
-46
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -45,11 +45,11 @@ def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOp
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_kwargs(request_body: dict) -> dict:
|
def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
|
||||||
kw = {
|
kw = {
|
||||||
"model": request_body.get("model"),
|
"model": request_body.get("model"),
|
||||||
"messages": request_body.get("messages", []),
|
"messages": request_body.get("messages", []),
|
||||||
"stream": False,
|
"stream": stream,
|
||||||
}
|
}
|
||||||
if request_body.get("temperature") is not None:
|
if request_body.get("temperature") is not None:
|
||||||
kw["temperature"] = request_body["temperature"]
|
kw["temperature"] = request_body["temperature"]
|
||||||
@@ -92,7 +92,7 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
|||||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
gen = await client.chat.completions.create(
|
gen = await client.chat.completions.create(
|
||||||
**_create_kwargs(request_body), stream=True
|
**_create_kwargs(request_body, stream=True)
|
||||||
)
|
)
|
||||||
async for chunk in gen:
|
async for chunk in gen:
|
||||||
if not chunk.choices:
|
if not chunk.choices:
|
||||||
@@ -179,3 +179,4 @@ def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
|
|||||||
with client:
|
with client:
|
||||||
resp = client.embeddings.create(**_embed_kwargs(input, model))
|
resp = client.embeddings.create(**_embed_kwargs(input, model))
|
||||||
return [d.embedding for d in resp.data]
|
return [d.embedding for d in resp.data]
|
||||||
|
|
||||||
@@ -1759,3 +1759,23 @@ html.dark .ai-sending-timeout {
|
|||||||
.ai-cancel-btn:hover {
|
.ai-cancel-btn:hover {
|
||||||
background: color-mix(in srgb, #e74c3c 15%, var(--color-bg-tertiary)) !important;
|
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 [sending, setSending] = useState(false);
|
||||||
const [sendingTimeout, setSendingTimeout] = useState(false);
|
const [sendingTimeout, setSendingTimeout] = useState(false);
|
||||||
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
|
||||||
|
const [streamingId, setStreamingId] = useState<string | null>(null);
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [loadingConversations, setLoadingConversations] = useState(true);
|
const [loadingConversations, setLoadingConversations] = useState(true);
|
||||||
@@ -933,43 +935,12 @@ const ChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle send message - CALLS REAL API
|
// 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退
|
||||||
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
|
const sendLegacy = async (
|
||||||
const sendText = async (content: string) => {
|
payload: any,
|
||||||
if (!content.trim() || sending || !selectedConversation) return;
|
tempUserMsg: Message,
|
||||||
setSending(true);
|
) => {
|
||||||
setSendingTimeout(false);
|
const res = await chatAPI.sendMessage(selectedConversation!.id, payload);
|
||||||
// 60s 超时警告
|
|
||||||
if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
|
|
||||||
sendingTimerRef.current = setTimeout(() => {
|
|
||||||
setSendingTimeout(true);
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
// Add user message optimistically
|
|
||||||
const tempUserMsg: Message = {
|
|
||||||
id: `temp_${Date.now()}`,
|
|
||||||
conversationId: selectedConversation?.id || '',
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
tokenUsed: 0,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, tempUserMsg]);
|
|
||||||
|
|
||||||
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);
|
const data = unwrapData(res);
|
||||||
|
|
||||||
// Replace temporary user message and add assistant response
|
// Replace temporary user message and add assistant response
|
||||||
@@ -1028,11 +999,158 @@ const ChatPage: React.FC = () => {
|
|||||||
messages: recentMsgs,
|
messages: recentMsgs,
|
||||||
}).catch(() => {});
|
}).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;
|
||||||
|
setSending(true);
|
||||||
|
setSendingTimeout(false);
|
||||||
|
// 60s 超时警告
|
||||||
|
if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
|
||||||
|
sendingTimerRef.current = setTimeout(() => {
|
||||||
|
setSendingTimeout(true);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// Add user message optimistically
|
||||||
|
const tempUserMsg: Message = {
|
||||||
|
id: `temp_${Date.now()}`,
|
||||||
|
conversationId: selectedConversation?.id || '',
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
tokenUsed: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
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 {
|
||||||
|
await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
|
// 移除流式占位,回退非流式重新发送
|
||||||
// Remove optimistic message on error
|
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));
|
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
|
||||||
alert('发送失败: ' + (err.message || err.detail || '未知错误'));
|
alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
|
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
|
||||||
setSending(false);
|
setSending(false);
|
||||||
@@ -1286,6 +1404,22 @@ const ChatPage: React.FC = () => {
|
|||||||
{msg.role === 'assistant' && steps.length > 0 && (
|
{msg.role === 'assistant' && steps.length > 0 && (
|
||||||
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
<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}`}>
|
<div className={`message-bubble ${msg.role}`}>
|
||||||
{clarify ? (
|
{clarify ? (
|
||||||
<ClarifyCard
|
<ClarifyCard
|
||||||
@@ -1306,7 +1440,7 @@ const ChatPage: React.FC = () => {
|
|||||||
}}>🔄 重试</button>
|
}}>🔄 重试</button>
|
||||||
</div>
|
</div>
|
||||||
) : msg.role === 'assistant' ? (
|
) : 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>
|
<span>{msg.content}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1370,7 +1504,7 @@ const ChatPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
{sending && (
|
{sending && !streamingId && (
|
||||||
<div className="message-row assistant">
|
<div className="message-row assistant">
|
||||||
<div className={`message-bubble assistant${sendingTimeout ? ' ai-sending-timeout' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className={`message-bubble assistant${sendingTimeout ? ' ai-sending-timeout' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<IconSpinner />
|
<IconSpinner />
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface Message {
|
|||||||
content: string;
|
content: string;
|
||||||
tokenUsed: number;
|
tokenUsed: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
reasoning?: string;
|
||||||
used_skills?: string[];
|
used_skills?: string[];
|
||||||
used_mcp?: string[];
|
used_mcp?: string[];
|
||||||
process_steps?: Array<{
|
process_steps?: Array<{
|
||||||
|
|||||||
Reference in new issue
Block a user