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:
QihuAI Design committed 2026-07-24 18:25:15 +08:00
1 parent 3788a93425
commit 256d9f33e6
3 files changed
+2182 -2031

No files matched your search

+24 -6
View File
@@ -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,7 +4817,15 @@ 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})
message = {"content": _sc, "tool_calls": [], "reasoning": _sr} 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}
choice = {"message": message} choice = {"message": message}
data = {"choices": [choice], "usage": {}} data = {"choices": [choice], "usage": {}}
else: else:
@@ -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",
+222 -182
View File
@@ -1,182 +1,222 @@
"""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, stream: bool = False) -> 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": stream, "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: _content_parts = []
gen = await client.chat.completions.create( _reasoning_parts = []
**_create_kwargs(request_body, stream=True) _tool_acc = {} # index -> {id, type, name, arguments}
) try:
async for chunk in gen: gen = await client.chat.completions.create(
if not chunk.choices: **_create_kwargs(request_body, stream=True)
continue )
delta = chunk.choices[0].delta async for chunk in gen:
rc = getattr(delta, "reasoning_content", None) if not chunk.choices:
if rc: continue
yield ("reasoning", rc) delta = chunk.choices[0].delta
ct = getattr(delta, "content", None) rc = getattr(delta, "reasoning_content", None)
if ct: if rc:
yield ("content", ct) _reasoning_parts.append(rc)
finally: yield ("reasoning", rc)
await client.close() ct = getattr(delta, "content", None)
if ct:
return _gen() _content_parts.append(ct)
yield ("content", ct)
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) # 累积 OpenAI 流式 tool_calls(供后续工具循环执行)
async with client: tcs = getattr(delta, "tool_calls", None)
resp = await client.chat.completions.create(**_create_kwargs(request_body)) if tcs:
msg = resp.choices[0].message for tc in tcs:
return { _idx = getattr(tc, "index", 0)
"choices": [ _slot = _tool_acc.setdefault(
{ _idx, {"id": None, "type": "function", "name": "", "arguments": ""}
"message": { )
"content": msg.content or "", _tcid = getattr(tc, "id", None)
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), if _tcid:
"reasoning_content": getattr(msg, "reasoning_content", None), _slot["id"] = _tcid
} _fn = getattr(tc, "function", None)
} if _fn is not None:
], _fn_name = getattr(_fn, "name", None)
"usage": (resp.usage.model_dump() if resp.usage else {}), if _fn_name:
} _slot["name"] += _fn_name
_fn_args = getattr(_fn, "arguments", None)
if _fn_args:
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI: _slot["arguments"] += _fn_args
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。""" finally:
base = _base_url(api_url) await client.close()
http_client = httpx.Client(proxy=proxy, trust_env=False) # 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client) _tool_calls = None
if _tool_acc:
_tool_calls = []
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120): for _i in sorted(_tool_acc.keys()):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。""" _s = _tool_acc[_i]
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) _tool_calls.append({
with client: "id": _s["id"],
resp = client.chat.completions.create(**_create_kwargs(request_body)) "type": "function",
msg = resp.choices[0].message "function": {"name": _s["name"], "arguments": _s["arguments"]},
return { })
"choices": [ yield ("message", {
{ "content": "".join(_content_parts),
"message": { "tool_calls": _tool_calls,
"content": msg.content or "", "reasoning_content": "".join(_reasoning_parts) or None,
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), })
"reasoning_content": getattr(msg, "reasoning_content", None),
} return _gen()
}
], client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
"usage": (resp.usage.model_dump() if resp.usage else {}), async with client:
} resp = await client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message
return {
# --------------------------------------------------------------------------- "choices": [
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用 {
# --------------------------------------------------------------------------- "message": {
def _embed_kwargs(input, model): "content": msg.content or "",
return {"model": model, "input": input if isinstance(input, list) else [input]} "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: }
"""统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。 ],
"usage": (resp.usage.model_dump() if resp.usage else {}),
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx }
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
"""
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
async with client: """同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
resp = await client.embeddings.create(**_embed_kwargs(input, model)) base = _base_url(api_url)
return [d.embedding for d in resp.data] 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 embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。""" def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
with client: client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
resp = client.embeddings.create(**_embed_kwargs(input, model)) with client:
return [d.embedding for d in resp.data] 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]
File diff suppressed because it is too large. Load diff