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 18:25:11. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
3788a93425
commit
256d9f33e6
3 files changed
+180
-29
No files matched your search
@@ -1999,6 +1999,15 @@ async def _send_message_core(
|
|||||||
|
|
||||||
process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示
|
process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示
|
||||||
|
|
||||||
|
async def _push_step(step: dict):
|
||||||
|
"""追加过程步骤,并在流式场景实时推送给前端(step 事件)。"""
|
||||||
|
await _push_step(step)
|
||||||
|
if on_event is not None:
|
||||||
|
try:
|
||||||
|
await on_event({"type": "step", "step": step, "index": len(process_steps) - 1})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -4489,7 +4498,7 @@ async def _send_message_core(
|
|||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
process_steps.append({
|
await _push_step({
|
||||||
|
|
||||||
"type": "skill",
|
"type": "skill",
|
||||||
|
|
||||||
@@ -4791,10 +4800,11 @@ async def _send_message_core(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids):
|
if stream and on_event is not None:
|
||||||
|
|
||||||
_sc = ""
|
_sc = ""
|
||||||
_sr = ""
|
_sr = ""
|
||||||
|
_final_msg = None
|
||||||
async for _kind, _text in await achat_complete(
|
async for _kind, _text in await achat_complete(
|
||||||
api_url, api_key, request_body,
|
api_url, api_key, request_body,
|
||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
@@ -4807,6 +4817,14 @@ async def _send_message_core(
|
|||||||
elif _kind == "reasoning":
|
elif _kind == "reasoning":
|
||||||
_sr += _text
|
_sr += _text
|
||||||
await on_event({"type": "reasoning", "content": _text})
|
await on_event({"type": "reasoning", "content": _text})
|
||||||
|
elif _kind == "message":
|
||||||
|
_final_msg = _text
|
||||||
|
if _final_msg is not None:
|
||||||
|
_final_msg = dict(_final_msg)
|
||||||
|
if "reasoning_content" in _final_msg:
|
||||||
|
_final_msg["reasoning"] = _final_msg.pop("reasoning_content")
|
||||||
|
message = _final_msg
|
||||||
|
else:
|
||||||
message = {"content": _sc, "tool_calls": [], "reasoning": _sr}
|
message = {"content": _sc, "tool_calls": [], "reasoning": _sr}
|
||||||
choice = {"message": message}
|
choice = {"message": message}
|
||||||
data = {"choices": [choice], "usage": {}}
|
data = {"choices": [choice], "usage": {}}
|
||||||
@@ -4983,7 +5001,7 @@ async def _send_message_core(
|
|||||||
|
|
||||||
if _think and tool_calls:
|
if _think and tool_calls:
|
||||||
|
|
||||||
process_steps.append({
|
await _push_step({
|
||||||
|
|
||||||
"type": "thinking",
|
"type": "thinking",
|
||||||
|
|
||||||
@@ -5469,7 +5487,7 @@ async def _send_message_core(
|
|||||||
|
|
||||||
called_mcp_names.append(_srv.get("name", ""))
|
called_mcp_names.append(_srv.get("name", ""))
|
||||||
|
|
||||||
process_steps.append({
|
await _push_step({
|
||||||
|
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
|
|
||||||
@@ -5545,7 +5563,7 @@ async def _send_message_core(
|
|||||||
|
|
||||||
called_skill_names.append(_sk.get("name", ""))
|
called_skill_names.append(_sk.get("name", ""))
|
||||||
|
|
||||||
process_steps.append({
|
await _push_step({
|
||||||
|
|
||||||
"type": "skill",
|
"type": "skill",
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
|||||||
|
|
||||||
async def _gen():
|
async def _gen():
|
||||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||||
|
_content_parts = []
|
||||||
|
_reasoning_parts = []
|
||||||
|
_tool_acc = {} # index -> {id, type, name, arguments}
|
||||||
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)
|
||||||
@@ -100,12 +103,49 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
|||||||
delta = chunk.choices[0].delta
|
delta = chunk.choices[0].delta
|
||||||
rc = getattr(delta, "reasoning_content", None)
|
rc = getattr(delta, "reasoning_content", None)
|
||||||
if rc:
|
if rc:
|
||||||
|
_reasoning_parts.append(rc)
|
||||||
yield ("reasoning", rc)
|
yield ("reasoning", rc)
|
||||||
ct = getattr(delta, "content", None)
|
ct = getattr(delta, "content", None)
|
||||||
if ct:
|
if ct:
|
||||||
|
_content_parts.append(ct)
|
||||||
yield ("content", ct)
|
yield ("content", ct)
|
||||||
|
# 累积 OpenAI 流式 tool_calls(供后续工具循环执行)
|
||||||
|
tcs = getattr(delta, "tool_calls", None)
|
||||||
|
if tcs:
|
||||||
|
for tc in tcs:
|
||||||
|
_idx = getattr(tc, "index", 0)
|
||||||
|
_slot = _tool_acc.setdefault(
|
||||||
|
_idx, {"id": None, "type": "function", "name": "", "arguments": ""}
|
||||||
|
)
|
||||||
|
_tcid = getattr(tc, "id", None)
|
||||||
|
if _tcid:
|
||||||
|
_slot["id"] = _tcid
|
||||||
|
_fn = getattr(tc, "function", None)
|
||||||
|
if _fn is not None:
|
||||||
|
_fn_name = getattr(_fn, "name", None)
|
||||||
|
if _fn_name:
|
||||||
|
_slot["name"] += _fn_name
|
||||||
|
_fn_args = getattr(_fn, "arguments", None)
|
||||||
|
if _fn_args:
|
||||||
|
_slot["arguments"] += _fn_args
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
# 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
|
||||||
|
_tool_calls = None
|
||||||
|
if _tool_acc:
|
||||||
|
_tool_calls = []
|
||||||
|
for _i in sorted(_tool_acc.keys()):
|
||||||
|
_s = _tool_acc[_i]
|
||||||
|
_tool_calls.append({
|
||||||
|
"id": _s["id"],
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": _s["name"], "arguments": _s["arguments"]},
|
||||||
|
})
|
||||||
|
yield ("message", {
|
||||||
|
"content": "".join(_content_parts),
|
||||||
|
"tool_calls": _tool_calls,
|
||||||
|
"reasoning_content": "".join(_reasoning_parts) or None,
|
||||||
|
})
|
||||||
|
|
||||||
return _gen()
|
return _gen()
|
||||||
|
|
||||||
|
|||||||
@@ -548,6 +548,54 @@ const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 思考过程收拢式展示(缺陷5:默认折叠;fresh 流式消息自动展开,数秒后自动收拢)
|
||||||
|
const ReasoningBlock: React.FC<{ text: string; fresh: boolean }> = ({ text, fresh }) => {
|
||||||
|
const [open, setOpen] = useState(fresh);
|
||||||
|
const [isFresh, setFreshState] = useState(fresh);
|
||||||
|
const touchedRef = useRef(false);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fresh) {
|
||||||
|
setOpen(false);
|
||||||
|
setFreshState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
setFreshState(true);
|
||||||
|
touchedRef.current = false;
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
if (!touchedRef.current) {
|
||||||
|
setOpen(false);
|
||||||
|
setTimeout(() => setFreshState(false), 500);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
|
||||||
|
}, [fresh]);
|
||||||
|
if (!text) return null;
|
||||||
|
const toggle = () => {
|
||||||
|
touchedRef.current = true;
|
||||||
|
setFreshState(false);
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
setOpen(o => !o);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<details className={`ai-process ai-process-reasoning${isFresh ? ' ai-process-fresh' : ''}`} open={open}>
|
||||||
|
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
|
||||||
|
<span className="ai-process-badge">🤔</span>
|
||||||
|
AI 思考过程{open ? '(收拢中... 点击收起)' : '(点击展开)'}
|
||||||
|
</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">{text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Main Component =====
|
// ===== Main Component =====
|
||||||
const ChatPage: React.FC = () => {
|
const ChatPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
@@ -561,6 +609,9 @@ const ChatPage: React.FC = () => {
|
|||||||
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
|
// 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
|
||||||
const [streamingId, setStreamingId] = useState<string | null>(null);
|
const [streamingId, setStreamingId] = useState<string | null>(null);
|
||||||
|
// 流式中断控制器(缺陷4:支持「停止生成」)
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const stopStreaming = () => { try { abortRef.current?.abort(); } catch { /* noop */ } };
|
||||||
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);
|
||||||
@@ -1009,6 +1060,8 @@ const ChatPage: React.FC = () => {
|
|||||||
assistantId: string,
|
assistantId: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const token = localStorage.getItem('aiflow_token');
|
const token = localStorage.getItem('aiflow_token');
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
|
const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1016,6 +1069,7 @@ const ChatPage: React.FC = () => {
|
|||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!resp.ok || !resp.body) {
|
if (!resp.ok || !resp.body) {
|
||||||
throw new Error(`SSE 连接失败 (${resp.status})`);
|
throw new Error(`SSE 连接失败 (${resp.status})`);
|
||||||
@@ -1026,9 +1080,32 @@ const ChatPage: React.FC = () => {
|
|||||||
let buffer = '';
|
let buffer = '';
|
||||||
let accReasoning = '';
|
let accReasoning = '';
|
||||||
let gotDone = false;
|
let gotDone = false;
|
||||||
|
let pendingDelta = '';
|
||||||
|
let reasoningDirty = false;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
const patchAssistant = (patch: Partial<Message>) => {
|
// 缺陷4:用 requestAnimationFrame 合并同一帧内的多个 delta/reasoning,避免逐 token 重渲染抖动
|
||||||
setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, ...patch } : m)));
|
const flushBuffered = () => {
|
||||||
|
rafId = null;
|
||||||
|
if (!pendingDelta && !reasoningDirty) return;
|
||||||
|
const piece = pendingDelta;
|
||||||
|
pendingDelta = '';
|
||||||
|
const r = reasoningDirty ? accReasoning : null;
|
||||||
|
reasoningDirty = false;
|
||||||
|
setMessages(prev => prev.map(m => {
|
||||||
|
if (m.id !== assistantId) return m;
|
||||||
|
const patch: any = {};
|
||||||
|
if (piece) patch.content = (m.content || '') + piece;
|
||||||
|
if (r !== null) patch.reasoning = r;
|
||||||
|
return { ...m, ...patch };
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (rafId == null) rafId = requestAnimationFrame(flushBuffered);
|
||||||
|
};
|
||||||
|
const finalizeFlush = () => {
|
||||||
|
if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; }
|
||||||
|
flushBuffered();
|
||||||
};
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -1044,13 +1121,27 @@ const ChatPage: React.FC = () => {
|
|||||||
let evt: any;
|
let evt: any;
|
||||||
try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
||||||
if (evt.type === 'delta') {
|
if (evt.type === 'delta') {
|
||||||
const piece = evt.content || '';
|
pendingDelta += evt.content || '';
|
||||||
setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, content: (m.content || '') + piece } : m)));
|
scheduleFlush();
|
||||||
} else if (evt.type === 'reasoning') {
|
} else if (evt.type === 'reasoning') {
|
||||||
accReasoning += evt.content || '';
|
accReasoning += evt.content || '';
|
||||||
patchAssistant({ reasoning: accReasoning } as any);
|
reasoningDirty = true;
|
||||||
|
scheduleFlush();
|
||||||
|
} else if (evt.type === 'step') {
|
||||||
|
// 缺陷2:实时增量过程步骤,前端随事件收拢式渲染
|
||||||
|
const step = evt.step;
|
||||||
|
const idx = typeof evt.index === 'number' ? evt.index : -1;
|
||||||
|
setMessages(prev => prev.map(m => {
|
||||||
|
if (m.id !== assistantId) return m;
|
||||||
|
const steps: any[] = (m as any).process_steps || [];
|
||||||
|
const next = steps.slice();
|
||||||
|
if (idx >= 0 && idx < next.length) next[idx] = step;
|
||||||
|
else next.push(step);
|
||||||
|
return { ...m, process_steps: next };
|
||||||
|
}));
|
||||||
} else if (evt.type === 'done') {
|
} else if (evt.type === 'done') {
|
||||||
gotDone = true;
|
gotDone = true;
|
||||||
|
finalizeFlush();
|
||||||
const finalId = evt.message_id || assistantId;
|
const finalId = evt.message_id || assistantId;
|
||||||
setMessages(prev => prev.map(m => {
|
setMessages(prev => prev.map(m => {
|
||||||
if (m.id !== assistantId) return m;
|
if (m.id !== assistantId) return m;
|
||||||
@@ -1065,6 +1156,7 @@ const ChatPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
setStreamingId(null);
|
setStreamingId(null);
|
||||||
|
abortRef.current = null;
|
||||||
if (evt.generating) startMediaPolling(convId, finalId);
|
if (evt.generating) startMediaPolling(convId, finalId);
|
||||||
setFreshMsgIds(prev => new Set(prev).add(finalId));
|
setFreshMsgIds(prev => new Set(prev).add(finalId));
|
||||||
loadConversations();
|
loadConversations();
|
||||||
@@ -1079,12 +1171,26 @@ const ChatPage: React.FC = () => {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else if (evt.type === 'error') {
|
} else if (evt.type === 'error') {
|
||||||
throw new Error(evt.message || '生成失败');
|
// 缺陷4:容错——保留已生成内容,不强删,标记中断后优雅收尾
|
||||||
|
finalizeFlush();
|
||||||
|
setMessages(prev => prev.map(m => {
|
||||||
|
if (m.id !== assistantId) return m;
|
||||||
|
const note = '\n\n_(生成中断:' + (evt.message || '未知错误') + ')_';
|
||||||
|
return { ...m, content: (m.content || '') + note };
|
||||||
|
}));
|
||||||
|
setStreamingId(null);
|
||||||
|
abortRef.current = null;
|
||||||
|
setFreshMsgIds(prev => new Set(prev).add(assistantId));
|
||||||
|
loadConversations();
|
||||||
|
gotDone = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!gotDone) {
|
if (!gotDone) {
|
||||||
// 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
|
// 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
|
||||||
|
finalizeFlush();
|
||||||
|
abortRef.current = null;
|
||||||
setStreamingId(null);
|
setStreamingId(null);
|
||||||
throw new Error('流式响应未正常结束');
|
throw new Error('流式响应未正常结束');
|
||||||
}
|
}
|
||||||
@@ -1405,20 +1511,7 @@ const ChatPage: React.FC = () => {
|
|||||||
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
||||||
)}
|
)}
|
||||||
{msg.role === 'assistant' && (msg as any).reasoning && (
|
{msg.role === 'assistant' && (msg as any).reasoning && (
|
||||||
<details className="ai-process ai-process-reasoning" open>
|
<ReasoningBlock text={(msg as any).reasoning} fresh={freshMsgIds.has(msg.id)} />
|
||||||
<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 ? (
|
||||||
@@ -1708,11 +1801,11 @@ const ChatPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="send-btn"
|
className="send-btn"
|
||||||
onClick={handleSend}
|
onClick={streamingId ? stopStreaming : handleSend}
|
||||||
disabled={!inputValue.trim() || sending}
|
disabled={!streamingId && (!inputValue.trim() || sending)}
|
||||||
title="发送 (Enter)"
|
title={streamingId ? '停止生成' : '发送 (Enter)'}
|
||||||
>
|
>
|
||||||
{sending ? <IconSpinner /> : <IconSend />}
|
{streamingId ? <IconStop /> : (sending ? <IconSpinner /> : <IconSend />)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user