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