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).
183 lines
7.5 KiB
Python
183 lines
7.5 KiB
Python
"""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]
|
||
|