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

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+182 -181
View File
@@ -1,181 +1,182 @@
"""Shared, standards-based LLM client for web chat + IM bot. """Shared, standards-based LLM client for web chat + IM bot.
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK 平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲 (`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得: OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
* 原生 SSE 流式(`stream=True`); * 原生 SSE 流式(`stream=True`);
* 结构化的 tool_calls / usage 解析; * 结构化的 tool_calls / usage 解析;
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。 * 统一的代理(trust_env=False + 显式 proxy)与超时处理。
本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与 本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与
IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」 IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」
红线在模型层的落地。 红线在模型层的落地。
API: API:
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False) achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
- stream=False -> dict,形状对齐上游 `data` 响应: - stream=False -> dict,形状对齐上游 `data` 响应:
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}} {"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量 - stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
""" """
from __future__ import annotations from __future__ import annotations
import httpx import httpx
from openai import AsyncOpenAI, OpenAI from openai import AsyncOpenAI, OpenAI
def _base_url(api_url: str) -> str: def _base_url(api_url: str) -> str:
"""从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。""" """从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。"""
u = (api_url or "").rstrip() u = (api_url or "").rstrip()
for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"): for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"):
if u.endswith(suf): if u.endswith(suf):
return u[: -len(suf)] return u[: -len(suf)]
return u return u
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI: def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI:
base = _base_url(api_url) base = _base_url(api_url)
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502; # 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
# 代理经 proxy 显式传入(AGNES 等),未传则直连。 # 代理经 proxy 显式传入(AGNES 等),未传则直连。
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False) http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
return AsyncOpenAI( return AsyncOpenAI(
base_url=base, base_url=base,
api_key=api_key or "EMPTY", api_key=api_key or "EMPTY",
timeout=timeout, timeout=timeout,
http_client=http_client, http_client=http_client,
) )
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"]
if request_body.get("top_p") is not None: if request_body.get("top_p") is not None:
kw["top_p"] = request_body["top_p"] kw["top_p"] = request_body["top_p"]
if request_body.get("max_tokens") is not None: if request_body.get("max_tokens") is not None:
kw["max_tokens"] = request_body["max_tokens"] kw["max_tokens"] = request_body["max_tokens"]
if request_body.get("stop") is not None: if request_body.get("stop") is not None:
kw["stop"] = request_body["stop"] kw["stop"] = request_body["stop"]
tools = request_body.get("tools") tools = request_body.get("tools")
if tools: if tools:
kw["tools"] = tools kw["tools"] = tools
kw["tool_choice"] = request_body.get("tool_choice", "auto") kw["tool_choice"] = request_body.get("tool_choice", "auto")
return kw return kw
def _normalize_tool_calls(tcs): def _normalize_tool_calls(tcs):
if not tcs: if not tcs:
return None return None
out = [] out = []
for tc in tcs: for tc in tcs:
out.append( out.append(
{ {
"id": getattr(tc, "id", None), "id": getattr(tc, "id", None),
"type": "function", "type": "function",
"function": { "function": {
"name": getattr(tc.function, "name", None), "name": getattr(tc.function, "name", None),
"arguments": getattr(tc.function, "arguments", "") or "", "arguments": getattr(tc.function, "arguments", "") or "",
}, },
} }
) )
return out return out
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False): async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False):
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。""" """统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
if stream: if stream:
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)
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:
continue continue
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:
yield ("reasoning", rc) yield ("reasoning", rc)
ct = getattr(delta, "content", None) ct = getattr(delta, "content", None)
if ct: if ct:
yield ("content", ct) yield ("content", ct)
finally: finally:
await client.close() await client.close()
return _gen() return _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)
async with client: async with client:
resp = await client.chat.completions.create(**_create_kwargs(request_body)) resp = await client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message msg = resp.choices[0].message
return { return {
"choices": [ "choices": [
{ {
"message": { "message": {
"content": msg.content or "", "content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None), "reasoning_content": getattr(msg, "reasoning_content", None),
} }
} }
], ],
"usage": (resp.usage.model_dump() if resp.usage else {}), "usage": (resp.usage.model_dump() if resp.usage else {}),
} }
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI: def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。""" """同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
base = _base_url(api_url) base = _base_url(api_url)
http_client = httpx.Client(proxy=proxy, trust_env=False) http_client = httpx.Client(proxy=proxy, trust_env=False)
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client) return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120): def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。""" """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
with client: with client:
resp = client.chat.completions.create(**_create_kwargs(request_body)) resp = client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message msg = resp.choices[0].message
return { return {
"choices": [ "choices": [
{ {
"message": { "message": {
"content": msg.content or "", "content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None), "reasoning_content": getattr(msg, "reasoning_content", None),
} }
} }
], ],
"usage": (resp.usage.model_dump() if resp.usage else {}), "usage": (resp.usage.model_dump() if resp.usage else {}),
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用 # 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _embed_kwargs(input, model): def _embed_kwargs(input, model):
return {"model": model, "input": input if isinstance(input, list) else [input]} return {"model": model, "input": input if isinstance(input, list) else [input]}
async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。 """统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx 供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。 嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
""" """
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
async with client: async with client:
resp = await client.embeddings.create(**_embed_kwargs(input, model)) resp = await client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data] return [d.embedding for d in resp.data]
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。""" """同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
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]
+20
View File
@@ -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;
}
+212 -78
View File
@@ -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,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) => { const sendText = async (content: string) => {
if (!content.trim() || sending || !selectedConversation) return; if (!content.trim() || sending || !selectedConversation) return;
@@ -956,83 +1113,44 @@ const ChatPage: React.FC = () => {
}; };
setMessages(prev => [...prev, tempUserMsg]); 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 { try {
const res = await chatAPI.sendMessage(selectedConversation!.id, { await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
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(() => {});
}
} 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));
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); setStreamingId(null);
alert('发送失败: ' + (err.message || err.detail || '未知错误')); try {
await sendLegacy(payload, tempUserMsg);
} catch (e2: any) {
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
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 />
+1
View File
@@ -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<{