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:
1 parent
34648e4810
commit
037c965c26
5 files changed
+752
-21
No files matched your search
@@ -84,6 +84,7 @@ from ..auth import get_current_user
|
|||||||
from ..platform_knowledge import get_platform_knowledge
|
from ..platform_knowledge import get_platform_knowledge
|
||||||
|
|
||||||
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
||||||
|
from ..core.claw_adapters import SseAdapter
|
||||||
|
|
||||||
from ..middleware.log_service import LogService
|
from ..middleware.log_service import LogService
|
||||||
|
|
||||||
@@ -1570,38 +1571,37 @@ async def send_message_stream(
|
|||||||
req: SendMessageRequest,
|
req: SendMessageRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""SSE 流式聊天端点(Phase 0)。
|
"""SSE 流式聊天端点(#873)。
|
||||||
与普通 /messages 共用 _send_message_core,仅通过 stream=True + on_event
|
|
||||||
回调实时吐出增量(delta/reasoning),末尾推 done 事件(含 message_id)。
|
与 /messages 共用 EngineCore(_send_message_core),仅通过 stream=True +
|
||||||
|
on_event=SseAdapter 实时吐出增量(delta/reasoning/step),末尾推 done 终帧
|
||||||
|
(含 message_id 等)。SseAdapter 保证线格式与改造前逐字节一致。
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
queue = asyncio.Queue()
|
adapter = SseAdapter(
|
||||||
|
trace_id=f"sse-{conv_id}",
|
||||||
async def on_event(e):
|
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
|
||||||
await queue.put(e)
|
)
|
||||||
|
|
||||||
async def runner():
|
async def runner():
|
||||||
try:
|
try:
|
||||||
await _send_message_core(
|
await _send_message_core(
|
||||||
conv_id, req, db, current_user,
|
conv_id, req, db, current_user,
|
||||||
stream=True, on_event=on_event,
|
stream=True, on_event=adapter,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[sse] chat stream error: {exc}", exc_info=True)
|
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:
|
finally:
|
||||||
await queue.put({"type": "__end__"})
|
await adapter.close()
|
||||||
|
|
||||||
task = asyncio.create_task(runner())
|
task = asyncio.create_task(runner())
|
||||||
|
|
||||||
async def event_gen():
|
async def event_gen():
|
||||||
try:
|
try:
|
||||||
while True:
|
async for frame in adapter.frames():
|
||||||
e = await queue.get()
|
yield frame
|
||||||
if e.get("type") == "__end__":
|
|
||||||
break
|
|
||||||
yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n"
|
|
||||||
finally:
|
finally:
|
||||||
await task
|
await task
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1611,10 +1611,6 @@ async def send_message_stream(
|
|||||||
db.close()
|
db.close()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/conversations/{conv_id}/clear")
|
@router.post("/conversations/{conv_id}/clear")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1619,7 +1619,6 @@ async def _send_message_core(
|
|||||||
_ct = completion_tokens if "completion_tokens" in dir() else 0
|
_ct = completion_tokens if "completion_tokens" in dir() else 0
|
||||||
_uid = user_id if "user_id" in dir() else "unknown"
|
_uid = user_id if "user_id" in dir() else "unknown"
|
||||||
if _pt or _ct:
|
if _pt or _ct:
|
||||||
import asyncio
|
|
||||||
asyncio.create_task(_bill_tokens(
|
asyncio.create_task(_bill_tokens(
|
||||||
_uid, _pt, _ct,
|
_uid, _pt, _ct,
|
||||||
ai_model if "ai_model" in dir() else "unknown",
|
ai_model if "ai_model" in dir() else "unknown",
|
||||||
@@ -2899,7 +2898,7 @@ async def _send_message_core(
|
|||||||
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
|
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
|
||||||
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
|
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
|
||||||
if ai_model and ("image" in caps or "video" in caps) and not detect_media_intent(req.content):
|
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)
|
ai_model = resolve_auto_model("text", db)
|
||||||
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else []
|
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):
|
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
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
|||||||
return s
|
return s
|
||||||
return "(媒体生成未能返回有效内容,请重试)"
|
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 + 技能自动发现 + 平台知识库注入 =====
|
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
|
||||||
from ..platform_knowledge import get_platform_knowledge
|
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}。请稍后重试。)"
|
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"(AI 回复生成失败:{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()
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""双 Claw 适配器(#873 · P0)。
|
||||||
|
|
||||||
|
单引擎原则:
|
||||||
|
能力只在 EngineCore(`_send_message_core`) 实现一次;
|
||||||
|
Adapter 只做「引擎事件 → 通道」的搬运,不含任何业务判断。
|
||||||
|
|
||||||
|
┌──────────────┐ on_event(EngineEvent) ┌──────────────┐
|
||||||
|
│ EngineCore │ ────────────────────────► │ ClawAdapter │
|
||||||
|
└──────────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────┴────────────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
SseAdapter (Web Claw) BufferAdapter (IM Bot Claw)
|
||||||
|
逐帧 `data: {...}\\n\\n` 缓冲装配 → 整条 str | 媒体 dict
|
||||||
|
|
||||||
|
历史教训(#866):
|
||||||
|
IM 侧曾出现「第二份实现」,一次全量部署就把它冲掉、能力静默回退。
|
||||||
|
因此两个 Claw 的差异必须、且只能收敛在本文件的两个 Adapter 里。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.core.engine_event import (
|
||||||
|
CLAW_IM,
|
||||||
|
CLAW_WEB,
|
||||||
|
DELTA,
|
||||||
|
DONE,
|
||||||
|
ERROR,
|
||||||
|
REASONING,
|
||||||
|
STEP,
|
||||||
|
Diagnosis,
|
||||||
|
EngineEvent,
|
||||||
|
from_wire,
|
||||||
|
make_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["BaseClawAdapter", "SseAdapter", "BufferAdapter"]
|
||||||
|
|
||||||
|
# 单条回复的软上限:防止异常循环把内存/微信消息撑爆
|
||||||
|
_MAX_ASSEMBLED_CHARS = 20000
|
||||||
|
_MEDIA_RE = re.compile(r'<(img|video)[^>]*\ssrc="([^"]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseClawAdapter:
|
||||||
|
"""Adapter 基类。实例可直接当作 `on_event` 回调传给 EngineCore。"""
|
||||||
|
|
||||||
|
claw: str = ""
|
||||||
|
|
||||||
|
def __init__(self, *, trace_id: str = "", tenant_id: str = ""):
|
||||||
|
self.trace_id = trace_id or ""
|
||||||
|
self.tenant_id = tenant_id or ""
|
||||||
|
self._seq = 0
|
||||||
|
self.counts: Dict[str, int] = {}
|
||||||
|
|
||||||
|
# -- 供 EngineCore 直接 await adapter(event) -----------------------
|
||||||
|
async def __call__(self, event: Any) -> None:
|
||||||
|
await self.emit(event)
|
||||||
|
|
||||||
|
async def emit(self, event: Any) -> None:
|
||||||
|
ev = self._normalize(event)
|
||||||
|
if ev is None:
|
||||||
|
return
|
||||||
|
self.counts[ev.type] = self.counts.get(ev.type, 0) + 1
|
||||||
|
try:
|
||||||
|
await self._handle(ev)
|
||||||
|
except Exception as exc: # Adapter 永不把异常反噬引擎
|
||||||
|
logger.warning("[claw:%s] emit failed: %s", self.claw, exc, exc_info=True)
|
||||||
|
|
||||||
|
# -- 子类实现 -------------------------------------------------------
|
||||||
|
async def _handle(self, ev: EngineEvent) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# -- 归一化 ---------------------------------------------------------
|
||||||
|
def _normalize(self, event: Any) -> Optional[EngineEvent]:
|
||||||
|
ev = from_wire(event)
|
||||||
|
if ev is None:
|
||||||
|
logger.warning("[claw:%s] drop malformed event: %r", self.claw, event)
|
||||||
|
return None
|
||||||
|
self._seq += 1
|
||||||
|
if not ev.seq:
|
||||||
|
ev.seq = self._seq
|
||||||
|
if not ev.trace_id:
|
||||||
|
ev.trace_id = self.trace_id
|
||||||
|
if not ev.tenant_id:
|
||||||
|
ev.tenant_id = self.tenant_id
|
||||||
|
if not ev.claw:
|
||||||
|
ev.claw = self.claw
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Web Claw —— SSE 逐帧
|
||||||
|
# =====================================================================
|
||||||
|
class SseAdapter(BaseClawAdapter):
|
||||||
|
"""把引擎事件序列化为 SSE 帧并入队,由路由的 event_gen 逐帧 yield。
|
||||||
|
|
||||||
|
行为与改造前的 chat.py 内联实现逐字节一致:
|
||||||
|
`data: ` + json.dumps(wire, ensure_ascii=False) + `\\n\\n`
|
||||||
|
"""
|
||||||
|
|
||||||
|
claw = CLAW_WEB
|
||||||
|
_SENTINEL = {"type": "__end__"}
|
||||||
|
# 仅用于内部可观测(#876),默认不上线 —— 见 _thin()
|
||||||
|
_META_KEYS = ("seq", "trace_id", "tenant_id", "claw")
|
||||||
|
|
||||||
|
def __init__(self, queue: "Optional[asyncio.Queue]" = None, *, wire_meta: bool = False, **kw):
|
||||||
|
super().__init__(**kw)
|
||||||
|
self.queue: asyncio.Queue = queue if queue is not None else asyncio.Queue()
|
||||||
|
self.wire_meta = wire_meta
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def _thin(self, ev: EngineEvent, wire: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""裁剪线上元数据。
|
||||||
|
|
||||||
|
why: delta 是最热的帧,一次长回复有上千个。实测带全量元数据会让
|
||||||
|
单帧从 46B 膨胀到 117B(2.5x),2000 帧即多传 ~140KB 纯噪音,
|
||||||
|
而前端根本不消费这些键。故默认剥离,只在终帧留 trace_id ——
|
||||||
|
单帧成本可忽略,却是线上排障唯一的抓手。
|
||||||
|
"""
|
||||||
|
if self.wire_meta:
|
||||||
|
return wire
|
||||||
|
for k in self._META_KEYS:
|
||||||
|
wire.pop(k, None)
|
||||||
|
if ev.is_terminal() and ev.trace_id:
|
||||||
|
wire["trace_id"] = ev.trace_id
|
||||||
|
return wire
|
||||||
|
|
||||||
|
async def _handle(self, ev: EngineEvent) -> None:
|
||||||
|
await self.queue.put(self._thin(ev, ev.to_wire()))
|
||||||
|
|
||||||
|
async def emit_error(self, message: str, *, diagnosis: Any = None) -> None:
|
||||||
|
await self.emit(make_error(message, diagnosis=diagnosis))
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""通知 frames() 结束。幂等。"""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
await self.queue.put(dict(self._SENTINEL))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def frame(wire: Dict[str, Any]) -> str:
|
||||||
|
return "data: " + json.dumps(wire, ensure_ascii=False) + "\n\n"
|
||||||
|
|
||||||
|
async def frames(self):
|
||||||
|
"""异步生成 SSE 帧文本,直到收到哨兵。"""
|
||||||
|
while True:
|
||||||
|
wire = await self.queue.get()
|
||||||
|
if not isinstance(wire, dict):
|
||||||
|
continue
|
||||||
|
if wire.get("type") == "__end__":
|
||||||
|
break
|
||||||
|
yield self.frame(wire)
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# IM Bot Claw —— 缓冲装配
|
||||||
|
# =====================================================================
|
||||||
|
class BufferAdapter(BaseClawAdapter):
|
||||||
|
"""累积引擎事件,最后一次性装配为可直接推送给 IM 的回复。
|
||||||
|
|
||||||
|
IM 通道(微信/iLink)没有流式能力,也没有卡片 UI:
|
||||||
|
* delta 累积成正文;
|
||||||
|
* reasoning 默认丢弃(思维链不该刷屏微信),可用 keep_reasoning 打开;
|
||||||
|
* step 里的 error 级 diagnosis 降级成文本尾注,用户至少知道「哪一步炸了」;
|
||||||
|
* done 的 content 优先于累积的 delta(引擎已做最终裁剪/落库)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
claw = CLAW_IM
|
||||||
|
|
||||||
|
def __init__(self, *, keep_reasoning: bool = False, keep_diagnosis: bool = True, **kw):
|
||||||
|
super().__init__(**kw)
|
||||||
|
self.keep_reasoning = keep_reasoning
|
||||||
|
self.keep_diagnosis = keep_diagnosis
|
||||||
|
self._delta_parts: List[str] = []
|
||||||
|
self._reasoning_parts: List[str] = []
|
||||||
|
self.steps: List[Dict[str, Any]] = []
|
||||||
|
self.done: Optional[Dict[str, Any]] = None
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self.error_diagnosis: Optional[Diagnosis] = None
|
||||||
|
self.used_skills: List[Any] = []
|
||||||
|
self.used_mcp: List[Any] = []
|
||||||
|
self.generating: bool = False
|
||||||
|
|
||||||
|
async def _handle(self, ev: EngineEvent) -> None:
|
||||||
|
if ev.type == DELTA:
|
||||||
|
self._delta_parts.append(ev.content)
|
||||||
|
elif ev.type == REASONING:
|
||||||
|
if self.keep_reasoning:
|
||||||
|
self._reasoning_parts.append(ev.content)
|
||||||
|
elif ev.type == STEP:
|
||||||
|
self._record_step(ev)
|
||||||
|
elif ev.type == DONE:
|
||||||
|
wire = ev.to_wire()
|
||||||
|
self.done = wire
|
||||||
|
self.used_skills = wire.get("used_skills") or []
|
||||||
|
self.used_mcp = wire.get("used_mcp") or []
|
||||||
|
self.generating = bool(wire.get("generating"))
|
||||||
|
if wire.get("process_steps"):
|
||||||
|
self.steps = list(wire["process_steps"])
|
||||||
|
elif ev.type == ERROR:
|
||||||
|
self.error = str(ev.extra.get("message") or ev.content or "未知错误")
|
||||||
|
self.error_diagnosis = ev.diagnosis
|
||||||
|
|
||||||
|
def _record_step(self, ev: EngineEvent) -> None:
|
||||||
|
step = ev.extra.get("step")
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
return
|
||||||
|
idx = ev.extra.get("index")
|
||||||
|
if isinstance(idx, int) and 0 <= idx < len(self.steps):
|
||||||
|
self.steps[idx] = step
|
||||||
|
else:
|
||||||
|
self.steps.append(step)
|
||||||
|
|
||||||
|
# -- 装配 -----------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
"""当前累积正文(done 优先)。"""
|
||||||
|
if self.done and isinstance(self.done.get("content"), str) and self.done["content"]:
|
||||||
|
return self.done["content"]
|
||||||
|
return "".join(self._delta_parts)
|
||||||
|
|
||||||
|
def assemble(self) -> "str | dict":
|
||||||
|
"""产出最终回复:媒体则给结构化 dict,否则给纯文本。
|
||||||
|
|
||||||
|
返回的 dict 契约必须与 im_llm 媒体快路径一致:
|
||||||
|
{"__media__": True, "media_type": "image"|"video", "url": "..."}
|
||||||
|
—— im_bot_user.py 依赖它做微信内联发图/发视频。
|
||||||
|
"""
|
||||||
|
body = self.text
|
||||||
|
|
||||||
|
media = self._extract_media(body)
|
||||||
|
if media is not None:
|
||||||
|
return media
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
if self.error:
|
||||||
|
return self._render_failure()
|
||||||
|
if self.generating:
|
||||||
|
return "(内容正在生成中,请稍后在平台「AI 聊天」查看结果)"
|
||||||
|
return "(未生成有效回复,请换个说法再试一次)"
|
||||||
|
|
||||||
|
notes = self._diagnosis_notes()
|
||||||
|
if notes:
|
||||||
|
body = body.rstrip() + "\n\n" + "\n".join(notes)
|
||||||
|
if self.error:
|
||||||
|
body = body.rstrip() + "\n\n" + self._render_failure()
|
||||||
|
if self.generating:
|
||||||
|
body = body.rstrip() + "\n\n(媒体正在后台生成,稍后可在平台「AI 聊天」查看)"
|
||||||
|
|
||||||
|
if len(body) > _MAX_ASSEMBLED_CHARS:
|
||||||
|
body = body[:_MAX_ASSEMBLED_CHARS] + "\n…(内容过长已截断)"
|
||||||
|
return body
|
||||||
|
|
||||||
|
# -- 内部 -----------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _extract_media(body: str) -> Optional[Dict[str, Any]]:
|
||||||
|
s = (body or "").strip()
|
||||||
|
if not (s.startswith("<img") or s.startswith("<video")):
|
||||||
|
return None
|
||||||
|
m = _MEDIA_RE.search(s)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"__media__": True,
|
||||||
|
"media_type": "image" if m.group(1).lower() == "img" else "video",
|
||||||
|
"url": m.group(2),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _diagnosis_notes(self) -> List[str]:
|
||||||
|
"""把失败步骤的结构化诊断降级成文本尾注(IM 无卡片 UI)。"""
|
||||||
|
if not self.keep_diagnosis:
|
||||||
|
return []
|
||||||
|
notes: List[str] = []
|
||||||
|
for step in self.steps:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
diag = Diagnosis.from_dict(step.get("diagnosis"))
|
||||||
|
if diag is not None and diag.severity == "error":
|
||||||
|
notes.append(diag.to_text())
|
||||||
|
# 去重保序
|
||||||
|
return list(dict.fromkeys(notes))
|
||||||
|
|
||||||
|
def _render_failure(self) -> str:
|
||||||
|
if self.error_diagnosis is not None:
|
||||||
|
return self.error_diagnosis.to_text()
|
||||||
|
return f"⚠️ 生成中断:{self.error}"
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""双 Claw 引擎事件契约(#873 · P0)。
|
||||||
|
|
||||||
|
why:
|
||||||
|
平台有且只有两个 Claw 出口 —— Web Claw(SSE 逐帧) 与 IM Bot Claw(整条推送)。
|
||||||
|
在此之前,引擎事件是「散落在 chat_service 里的裸 dict」:
|
||||||
|
|
||||||
|
* 构造点分散,字段全靠记忆,漏一个键前端就静默不渲染;
|
||||||
|
* 没有 trace_id / tenant_id / claw 标签,可观测(#876)无处落脚;
|
||||||
|
* 失败只有一句人话字符串,前端靠 result.includes('失败') 猜错误 —— 极脆。
|
||||||
|
|
||||||
|
本模块把事件收敛为「唯一构造入口」:所有事件必须经 make_* 工厂产出,
|
||||||
|
再由 to_wire() 统一序列化。新增字段一律「非空才输出」,
|
||||||
|
保证既有前端(ChatPage.tsx)零改动。
|
||||||
|
|
||||||
|
线格式契约(authoritative —— 前端已消费,禁止破坏):
|
||||||
|
|
||||||
|
delta -> {"type":"delta", "content": str}
|
||||||
|
reasoning -> {"type":"reasoning", "content": str}
|
||||||
|
step -> {"type":"step", "step": {...}, "index"?: int}
|
||||||
|
done -> {"type":"done", "message_id", "conversation_id", "content",
|
||||||
|
"process_steps", "used_skills", "used_mcp", "generating"}
|
||||||
|
error -> {"type":"error", "message": str}
|
||||||
|
|
||||||
|
step 对象契约(AiProcessBlock 消费):
|
||||||
|
|
||||||
|
thinking -> {"type":"thinking", "text": str}
|
||||||
|
skill -> {"type":"skill", "name": str, "args": dict, "result": str}
|
||||||
|
mcp -> {"type":"mcp", "server": str, "name": str, "args": dict, "result": str}
|
||||||
|
|
||||||
|
附加(新增,前端可选消费): "diagnosis": {...}, "phase": "start|ok|fail"
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SCHEMA_VERSION",
|
||||||
|
"DELTA", "REASONING", "STEP", "DONE", "ERROR", "EVENT_TYPES",
|
||||||
|
"STEP_THINKING", "STEP_SKILL", "STEP_MCP", "STEP_AGENT", "STEP_KB", "STEP_MEDIA",
|
||||||
|
"PHASE_START", "PHASE_OK", "PHASE_FAIL",
|
||||||
|
"CLAW_WEB", "CLAW_IM",
|
||||||
|
"SEV_INFO", "SEV_WARN", "SEV_ERROR",
|
||||||
|
"Diagnosis", "EngineEvent",
|
||||||
|
"make_delta", "make_reasoning", "make_step", "make_done", "make_error",
|
||||||
|
"from_wire",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
# ---- 事件类型(线格式 type 值,前端已硬编码,禁止改名)----
|
||||||
|
DELTA = "delta"
|
||||||
|
REASONING = "reasoning"
|
||||||
|
STEP = "step"
|
||||||
|
DONE = "done"
|
||||||
|
ERROR = "error"
|
||||||
|
EVENT_TYPES = frozenset({DELTA, REASONING, STEP, DONE, ERROR})
|
||||||
|
|
||||||
|
# ---- step 子类(前端按此选图标 / 渲染分支)----
|
||||||
|
STEP_THINKING = "thinking"
|
||||||
|
STEP_SKILL = "skill"
|
||||||
|
STEP_MCP = "mcp"
|
||||||
|
STEP_AGENT = "agent" # 新增:外部 Agent 调用
|
||||||
|
STEP_KB = "kb" # 新增:知识库检索
|
||||||
|
STEP_MEDIA = "media" # 新增:媒体生成
|
||||||
|
|
||||||
|
# ---- 执行阶段 ----
|
||||||
|
PHASE_START = "start"
|
||||||
|
PHASE_OK = "ok"
|
||||||
|
PHASE_FAIL = "fail"
|
||||||
|
|
||||||
|
# ---- Claw 通道标签(可观测 #876 的核心维度)----
|
||||||
|
CLAW_WEB = "web"
|
||||||
|
CLAW_IM = "im"
|
||||||
|
|
||||||
|
# ---- 诊断严重级 ----
|
||||||
|
SEV_INFO = "info"
|
||||||
|
SEV_WARN = "warn"
|
||||||
|
SEV_ERROR = "error"
|
||||||
|
|
||||||
|
_SEV_ICON = {SEV_INFO: "ℹ️", SEV_WARN: "⚠️", SEV_ERROR: "❌"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Diagnosis:
|
||||||
|
"""结构化诊断。
|
||||||
|
|
||||||
|
取代「把失败原因塞进 result 字符串、前端再 includes('失败') 猜」的旧做法:
|
||||||
|
* Web Claw 可据此渲染诊断卡片(图标/配色/重试按钮);
|
||||||
|
* IM Bot Claw 无卡片 UI,用 to_text() 降级为纯文本行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
code: str = "" # 机器可读,如 "MCP_TIMEOUT" / "SKILL_NOT_FOUND"
|
||||||
|
severity: str = SEV_INFO # info | warn | error
|
||||||
|
summary: str = "" # 一句话结论(面向用户)
|
||||||
|
detail: str = "" # 细节(可含技术信息,前端折叠展示)
|
||||||
|
hint: str = "" # 可执行建议
|
||||||
|
retryable: bool = False # 是否值得重试
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"severity": self.severity,
|
||||||
|
"summary": self.summary,
|
||||||
|
"detail": self.detail,
|
||||||
|
"hint": self.hint,
|
||||||
|
"retryable": self.retryable,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_text(self) -> str:
|
||||||
|
"""IM Bot Claw 的纯文本降级渲染。"""
|
||||||
|
icon = _SEV_ICON.get(self.severity, "•")
|
||||||
|
lines = [f"{icon} {self.summary or self.code or '执行提示'}"]
|
||||||
|
if self.detail:
|
||||||
|
lines.append(self.detail)
|
||||||
|
if self.hint:
|
||||||
|
lines.append(f"建议:{self.hint}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Any) -> "Optional[Diagnosis]":
|
||||||
|
if isinstance(d, Diagnosis):
|
||||||
|
return d
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return None
|
||||||
|
return cls(
|
||||||
|
code=str(d.get("code") or ""),
|
||||||
|
severity=str(d.get("severity") or SEV_INFO),
|
||||||
|
summary=str(d.get("summary") or ""),
|
||||||
|
detail=str(d.get("detail") or ""),
|
||||||
|
hint=str(d.get("hint") or ""),
|
||||||
|
retryable=bool(d.get("retryable")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EngineEvent:
|
||||||
|
"""引擎事件的唯一表示。禁止在业务代码里手搓等价 dict —— 用 make_* 工厂。"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
content: str = ""
|
||||||
|
seq: int = 0
|
||||||
|
ts: float = field(default_factory=time.time)
|
||||||
|
trace_id: str = ""
|
||||||
|
tenant_id: str = ""
|
||||||
|
claw: str = ""
|
||||||
|
diagnosis: Optional[Diagnosis] = None
|
||||||
|
# extra 承载各事件类型的专属键(message_id / step / process_steps ...),
|
||||||
|
# 序列化时平铺到顶层,以保持与既有线格式逐字节兼容。
|
||||||
|
extra: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# -- 谓词 ---------------------------------------------------------
|
||||||
|
def is_terminal(self) -> bool:
|
||||||
|
return self.type in (DONE, ERROR)
|
||||||
|
|
||||||
|
def is_text_chunk(self) -> bool:
|
||||||
|
return self.type in (DELTA, REASONING)
|
||||||
|
|
||||||
|
# -- 序列化 -------------------------------------------------------
|
||||||
|
def to_wire(self) -> Dict[str, Any]:
|
||||||
|
"""序列化为通道线格式。
|
||||||
|
|
||||||
|
兼容铁律:既有键原样输出;新增键(seq/ts/claw/trace_id/tenant_id/diagnosis)
|
||||||
|
一律「非空才输出」,既不撑大帧体积,也不触发前端未知分支。
|
||||||
|
"""
|
||||||
|
wire: Dict[str, Any] = {"type": self.type}
|
||||||
|
|
||||||
|
if self.type in (DELTA, REASONING):
|
||||||
|
wire["content"] = self.content
|
||||||
|
elif self.content:
|
||||||
|
wire["content"] = self.content
|
||||||
|
|
||||||
|
for key, val in self.extra.items():
|
||||||
|
wire[key] = val
|
||||||
|
|
||||||
|
if self.seq:
|
||||||
|
wire["seq"] = self.seq
|
||||||
|
if self.claw:
|
||||||
|
wire["claw"] = self.claw
|
||||||
|
if self.trace_id:
|
||||||
|
wire["trace_id"] = self.trace_id
|
||||||
|
if self.tenant_id:
|
||||||
|
wire["tenant_id"] = self.tenant_id
|
||||||
|
if self.diagnosis is not None:
|
||||||
|
wire["diagnosis"] = self.diagnosis.to_dict()
|
||||||
|
return wire
|
||||||
|
|
||||||
|
def to_text(self) -> str:
|
||||||
|
"""无结构 UI 时(IM Bot)的降级文本;返回空串表示「本事件无需展示」。"""
|
||||||
|
if self.type in (DELTA, DONE):
|
||||||
|
return self.content
|
||||||
|
if self.type == ERROR:
|
||||||
|
if self.diagnosis is not None:
|
||||||
|
return self.diagnosis.to_text()
|
||||||
|
return f"❌ {self.extra.get('message') or self.content or '执行失败'}"
|
||||||
|
if self.type == STEP and self.diagnosis is not None:
|
||||||
|
return self.diagnosis.to_text()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 工厂 —— 唯一构造入口
|
||||||
|
# =====================================================================
|
||||||
|
def _ctx(kw: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""抽出通用上下文字段,避免每个工厂重复列参数。"""
|
||||||
|
return {
|
||||||
|
"seq": int(kw.pop("seq", 0) or 0),
|
||||||
|
"trace_id": str(kw.pop("trace_id", "") or ""),
|
||||||
|
"tenant_id": str(kw.pop("tenant_id", "") or ""),
|
||||||
|
"claw": str(kw.pop("claw", "") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_delta(content: str, **kw) -> EngineEvent:
|
||||||
|
"""正文增量。"""
|
||||||
|
return EngineEvent(type=DELTA, content=content or "", **_ctx(kw))
|
||||||
|
|
||||||
|
|
||||||
|
def make_reasoning(content: str, **kw) -> EngineEvent:
|
||||||
|
"""思维链增量。"""
|
||||||
|
return EngineEvent(type=REASONING, content=content or "", **_ctx(kw))
|
||||||
|
|
||||||
|
|
||||||
|
def make_step(
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
name: str = "",
|
||||||
|
text: str = "",
|
||||||
|
server: str = "",
|
||||||
|
args: Optional[Dict[str, Any]] = None,
|
||||||
|
result: str = "",
|
||||||
|
phase: str = "",
|
||||||
|
index: Optional[int] = None,
|
||||||
|
diagnosis: Any = None,
|
||||||
|
**kw,
|
||||||
|
) -> EngineEvent:
|
||||||
|
"""过程步骤(思考 / 技能 / MCP / Agent / KB / 媒体)。
|
||||||
|
|
||||||
|
index 用于「原地更新同一步骤」:先发 phase=start 占位,
|
||||||
|
执行完再用同一 index 发 phase=ok|fail 覆盖,前端即得到实时进度。
|
||||||
|
"""
|
||||||
|
step: Dict[str, Any] = {"type": kind}
|
||||||
|
if kind == STEP_THINKING:
|
||||||
|
step["text"] = text or name or ""
|
||||||
|
else:
|
||||||
|
if name:
|
||||||
|
step["name"] = name
|
||||||
|
if server:
|
||||||
|
step["server"] = server
|
||||||
|
if text:
|
||||||
|
step["text"] = text
|
||||||
|
step["args"] = args or {}
|
||||||
|
step["result"] = result or ""
|
||||||
|
if phase:
|
||||||
|
step["phase"] = phase
|
||||||
|
|
||||||
|
diag = Diagnosis.from_dict(diagnosis) if diagnosis is not None else None
|
||||||
|
if diag is not None:
|
||||||
|
step["diagnosis"] = diag.to_dict()
|
||||||
|
# result 为空时前端会判定为「运行中」,故失败必须给出可读 result 兜底
|
||||||
|
if not step["result"] and diag.severity == SEV_ERROR:
|
||||||
|
step["result"] = diag.to_text()
|
||||||
|
|
||||||
|
extra: Dict[str, Any] = {"step": step}
|
||||||
|
if isinstance(index, int) and index >= 0:
|
||||||
|
extra["index"] = index
|
||||||
|
|
||||||
|
return EngineEvent(type=STEP, diagnosis=diag, extra=extra, **_ctx(kw))
|
||||||
|
|
||||||
|
|
||||||
|
def make_done(
|
||||||
|
*,
|
||||||
|
message_id: Any = None,
|
||||||
|
conversation_id: Any = None,
|
||||||
|
content: str = "",
|
||||||
|
process_steps: Optional[list] = None,
|
||||||
|
used_skills: Optional[list] = None,
|
||||||
|
used_mcp: Optional[list] = None,
|
||||||
|
generating: bool = False,
|
||||||
|
**kw,
|
||||||
|
) -> EngineEvent:
|
||||||
|
"""终帧。字段集必须与既有 done 帧完全一致。"""
|
||||||
|
extra = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"content": content or "",
|
||||||
|
"process_steps": process_steps or [],
|
||||||
|
"used_skills": used_skills or [],
|
||||||
|
"used_mcp": used_mcp or [],
|
||||||
|
"generating": bool(generating),
|
||||||
|
}
|
||||||
|
return EngineEvent(type=DONE, content=content or "", extra=extra, **_ctx(kw))
|
||||||
|
|
||||||
|
|
||||||
|
def make_error(message: str, *, diagnosis: Any = None, **kw) -> EngineEvent:
|
||||||
|
"""错误终帧。"""
|
||||||
|
diag = Diagnosis.from_dict(diagnosis) if diagnosis is not None else None
|
||||||
|
return EngineEvent(
|
||||||
|
type=ERROR,
|
||||||
|
diagnosis=diag,
|
||||||
|
extra={"message": message or ""},
|
||||||
|
**_ctx(kw),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def from_wire(payload: Any) -> Optional[EngineEvent]:
|
||||||
|
"""把遗留裸 dict 提升为 EngineEvent(迁移期双向兼容的关键)。
|
||||||
|
|
||||||
|
chat_service.py 里仍有直接 await on_event({...}) 的老代码,
|
||||||
|
Adapter 收到后一律经此函数归一化,之后全链路只面对 EngineEvent。
|
||||||
|
"""
|
||||||
|
if isinstance(payload, EngineEvent):
|
||||||
|
return payload
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
d = dict(payload)
|
||||||
|
etype = str(d.pop("type", "") or "")
|
||||||
|
if not etype:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = d.pop("content", "")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
# done 帧的 content 理论上是 str;异常类型保留原值到 extra,避免丢数据
|
||||||
|
d["content"] = content
|
||||||
|
content = ""
|
||||||
|
|
||||||
|
diag = Diagnosis.from_dict(d.pop("diagnosis", None))
|
||||||
|
seq = d.pop("seq", 0)
|
||||||
|
trace_id = d.pop("trace_id", "")
|
||||||
|
tenant_id = d.pop("tenant_id", "")
|
||||||
|
claw = d.pop("claw", "")
|
||||||
|
d.pop("ts", None)
|
||||||
|
|
||||||
|
# done 帧的 content 需回填到 extra,才能在 to_wire() 时原样吐回
|
||||||
|
if etype == DONE:
|
||||||
|
d["content"] = content
|
||||||
|
|
||||||
|
return EngineEvent(
|
||||||
|
type=etype,
|
||||||
|
content=content,
|
||||||
|
seq=int(seq or 0),
|
||||||
|
trace_id=str(trace_id or ""),
|
||||||
|
tenant_id=str(tenant_id or ""),
|
||||||
|
claw=str(claw or ""),
|
||||||
|
diagnosis=diag,
|
||||||
|
extra=d,
|
||||||
|
)
|
||||||
Reference in new issue
Block a user