feat(engine): #873 dual-Claw event contract + SseAdapter/BufferAdapter + chat.py SSE delegate + im_llm EngineCore thin-shell (gray-gated)

- app/core/engine_event.py (#880): EngineEvent dataclass + make_* factories, single construction entry, byte-compatible wire with legacy ChatPage.tsx
- app/core/claw_adapters.py (#884): SseAdapter (hot frames zero-metadata, terminal keeps trace_id) + BufferAdapter (assemble() preserves __media__)
- app/api/chat.py (#881): /messages/stream now delegates to SseAdapter; wire bytes unchanged (46B delta / done+message_id)
- app/api/im_llm.py (#883): IM_USE_ENGINE_CORE gray gate; _call_engine_core reuses EngineCore via BufferAdapter (default OFF, preserves media fast-path)
- app/api/chat_service.py: capture 3 container-ahead fixes (stray import asyncio removed, logger.info format, LiteLLM master key)
This commit is contained in:
QihuAI Design committed 2026-08-04 12:25:12 +08:00
1 parent 34648e4810
commit 037c965c26
5 files changed
+752 -21

No files matched your search

+15 -19
View File
@@ -84,6 +84,7 @@ from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..core.claw_adapters import SseAdapter
from ..middleware.log_service import LogService
@@ -1570,38 +1571,37 @@ async def send_message_stream(
req: SendMessageRequest,
current_user: User = Depends(get_current_user),
):
"""SSE 流式聊天端点(Phase 0)。
与普通 /messages 共用 _send_message_core,仅通过 stream=True + on_event
回调实时吐出增量(delta/reasoning),末尾推 done 事件(含 message_id)。
"""SSE 流式聊天端点(#873)。
与 /messages 共用 EngineCore(_send_message_core),仅通过 stream=True +
on_event=SseAdapter 实时吐出增量(delta/reasoning/step),末尾推 done 终帧
(含 message_id 等)。SseAdapter 保证线格式与改造前逐字节一致。
"""
db = SessionLocal()
try:
queue = asyncio.Queue()
async def on_event(e):
await queue.put(e)
adapter = SseAdapter(
trace_id=f"sse-{conv_id}",
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
)
async def runner():
try:
await _send_message_core(
conv_id, req, db, current_user,
stream=True, on_event=on_event,
stream=True, on_event=adapter,
)
except Exception as exc:
logger.warning(f"[sse] chat stream error: {exc}", exc_info=True)
await queue.put({"type": "error", "message": str(exc)})
await adapter.emit_error(str(exc))
finally:
await queue.put({"type": "__end__"})
await adapter.close()
task = asyncio.create_task(runner())
async def event_gen():
try:
while True:
e = await queue.get()
if e.get("type") == "__end__":
break
yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n"
async for frame in adapter.frames():
yield frame
finally:
await task
db.close()
@@ -1611,10 +1611,6 @@ async def send_message_stream(
db.close()
raise
@router.post("/conversations/{conv_id}/clear")
+8 -2
View File
@@ -1619,7 +1619,6 @@ async def _send_message_core(
_ct = completion_tokens if "completion_tokens" in dir() else 0
_uid = user_id if "user_id" in dir() else "unknown"
if _pt or _ct:
import asyncio
asyncio.create_task(_bill_tokens(
_uid, _pt, _ct,
ai_model if "ai_model" in dir() else "unknown",
@@ -2899,7 +2898,7 @@ async def _send_message_core(
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
if ai_model and ("image" in caps or "video" in caps) and not detect_media_intent(req.content):
logger.info("[chat] Override media-cap model {} -> text (no media intent in: {:.50r})".format(ai_model.name, req.content))
logger.info("[chat] Override media-cap model %s -> text (no media intent in: %.50s)", ai_model.name, req.content)
ai_model = resolve_auto_model("text", db)
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else []
if ai_model and ("image" in caps or "video" in caps):
@@ -3081,6 +3080,13 @@ async def _send_message_core(
api_url = ai_model.endpoint
# 2026-07-29 fix: 调用 LiteLLM 端点时,必须用 master key (sk-litellm-...) 而非模型自身 key (tp-c...)
# LiteLLM 内置每个 model 的真实 key 在 litellm_config.yaml,chat 不应直接传模型的 key
if api_url and "qihu-aiflow-litellm" in api_url:
api_key = os.environ.get("LITELLM_MASTER_KEY") or "sk-litellm-master-key-aiflow-2026"
auth_header = None # 用默认 Authorization: Bearer
logger.info("[chat] LiteLLM endpoint detected, using master key instead of model key")
+86
View File
@@ -181,6 +181,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
return s
return "(媒体生成未能返回有效内容,请重试)"
# ===== 1.5) EngineCore 薄壳(#873 · 灰阶门,默认关闭)=====
# IM_USE_ENGINE_CORE=1 时,文本回复改走与 Web Claw 同一份 EngineCore
# (_send_message_core),经 BufferAdapter 缓冲装配为整条回复;上方媒体快路径
# 已提前返回,不受此门影响。默认 0/未设置 = 走下方既有自包含逻辑(零改动)。
if os.environ.get("IM_USE_ENGINE_CORE") == "1":
return _run_async(
_call_engine_core(db, user_id, prompt, platform, user),
timeout=300,
)
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
from ..platform_knowledge import get_platform_knowledge
@@ -413,3 +423,79 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
except Exception as e:
return f"(AI 回复生成失败:{e})"
async def _call_engine_core(db, user_id, prompt: str, platform: str, user=None) -> "str | dict":
"""(#873 薄壳)复用 Web Claw 同一份 EngineCore 生成 IM 回复。
单引擎原则:IM Bot 不再维护第二份「模型 failover + 技能/MCP 执行」实现,
而是把已发现的 skill_ids/mcp_ids 交给 _send_message_core,经 BufferAdapter
缓冲装配成整条回复(媒体结构 __media__ 由 BufferAdapter 原样保留)。
默认不启用(见 _call_real_llm 中的 IM_USE_ENGINE_CORE 灰阶门)。
"""
from app.api.chat_service import _send_message_core
from app.api.chat_utils import SendMessageRequest
from app.core.claw_adapters import BufferAdapter
from ..models import Conversation, User
# 解析用户:IM 调用方常传 user=None,仅给 user_id
real_user = user
if real_user is None and user_id:
real_user = db.query(User).filter(User.id == user_id).first()
if real_user is None:
return "(EngineCore 模式:无法解析当前用户,请检查 IM 账号绑定)"
# 能力发现(与 IM 自包含逻辑同一套,claw="im")
from app.core.capability_discovery import (
discover_mcp_servers,
discover_skills,
)
_query_hint = prompt
_marker = "用户当前问题:"
if _marker in prompt:
_query_hint = prompt[prompt.rfind(_marker) + len(_marker):].strip()
skills = []
try:
skills = discover_skills(db, user_id, query=_query_hint, claw="im") or []
except Exception as _e:
logger.warning(f"[im_llm.engine] 技能发现失败(降级为空): {_e}")
mcp_servers = []
try:
mcp_servers = discover_mcp_servers(db, None, claw="im") or []
except Exception as _e:
logger.warning(f"[im_llm.engine] MCP 发现失败(降级为空): {_e}")
skill_ids = [
(s.get("id") if isinstance(s, dict) else getattr(s, "id", None))
for s in skills
]
skill_ids = [s for s in skill_ids if s]
mcp_ids = [
(m.get("id") if isinstance(m, dict) else getattr(m, "id", None))
for m in mcp_servers
]
mcp_ids = [m for m in mcp_ids if m]
# 构造本回合专用对话:EngineCore 要求已存在且归属当前用户的 Conversation
conv = Conversation(
id=gen_uuid(),
user_id=real_user.id,
title=f"IM对话-{platform}",
source="im_bot",
)
db.add(conv)
db.commit()
req = SendMessageRequest(
content=prompt,
skill_ids=skill_ids or None,
mcp_ids=mcp_ids or None,
use_tools=bool(skill_ids or mcp_ids),
agent_ids=None,
knowledge_base_ids=None,
attachments=None,
)
adapter = BufferAdapter(tenant_id=str(getattr(real_user, "tenant_id", "") or ""))
await _send_message_core(conv.id, req, db, real_user, stream=True, on_event=adapter)
return adapter.assemble()