sse_core.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. # -*- coding: utf-8 -*-
  2. """
  3. SSE 核心工具函数 —— 流式事件生成器、文本提取、响应清理等通用辅助
  4. """
  5. import json
  6. import re
  7. import time
  8. import traceback
  9. from typing import AsyncGenerator
  10. from langgraph.types import Command # 程序化 interrupt() 恢复时需启用
  11. from tools.tool_names_cn import TOOL_NAME_CN as _TOOL_CN
  12. from db.chat_store import save_message, get_messages
  13. from tools.context_tracker import capture_context_usage
  14. # ── 工具名 → 中文描述映射 ──
  15. def _cn_tool_desc(tool_name: str) -> str:
  16. """将工具函数名映射为中文描述短语(用于 SSE message 字段)。
  17. 映射表统一维护在 tools/tool_names_cn.py 中,新增工具只需改那一处。
  18. """
  19. return _TOOL_CN.get(tool_name, tool_name)
  20. # ── 通用 SSE 流式事件生成器 ──
  21. async def sse_event_generator(
  22. agent,
  23. user_message: str,
  24. thread_id: str,
  25. session_id: str,
  26. config: dict,
  27. agent_cn_name: str = "智能助手",
  28. save_to_db: bool = True,
  29. original_user_message: str | None = None,
  30. interrupt_before: list | None = None,
  31. interrupt_after: list | None = None,
  32. ) -> AsyncGenerator[str, None]:
  33. """
  34. 通用 SSE 流式事件生成器。
  35. 使用 stream_mode=["updates", "messages"] 获取完整执行图景,
  36. 前端可根据 event type 区分:thinking / executing / tool_call / token / done / error。
  37. Args:
  38. save_to_db: 是否将消息保存到数据库。点选解读等非对话场景应设为 False。
  39. original_user_message: 用户的原始消息(不含系统注入的前缀)。
  40. 若提供,则 DB 保存此原始消息;user_message 仍作为 Agent 的输入。
  41. """
  42. # 保存用户消息(仅对话场景写库),优先使用原始消息
  43. if save_to_db:
  44. save_message(session_id, "user", original_user_message or user_message)
  45. # 用于收集完整回复
  46. full_response = ""
  47. start_time = time.time()
  48. _token_usage = {"prompt": 0, "completion": 0} # 从流中捕获的实际 token 用量
  49. try:
  50. # ── 发送 agent_start 事件,前端据此创建 Agent 卡片 ──
  51. yield f"data: {json.dumps({'type': 'agent_start', 'agent': agent_cn_name, 'cn_agent': agent_cn_name, 'message': f'「{agent_cn_name}」开始处理...'}, ensure_ascii=False)}\n\n"
  52. # 构建消息列表(包含历史消息 + 当前消息)
  53. if save_to_db:
  54. messages = [{"role": "user", "content": msg["content"]}
  55. for msg in get_messages(session_id, limit=50)]
  56. else:
  57. messages = []
  58. messages.append({"role": "user", "content": user_message})
  59. # 使用 agent.astream() 异步流式调用,多模式获取完整图景
  60. async for chunk in agent.astream(
  61. {"messages": messages},
  62. stream_mode=["updates", "messages"],
  63. config=config,
  64. interrupt_before=interrupt_before,
  65. interrupt_after=interrupt_after,
  66. version="v2",
  67. ):
  68. # 判断事件来源:主代理 vs 子代理
  69. is_subagent = any(s.startswith("tools:") for s in chunk.get("ns", []))
  70. source = "subagent" if is_subagent else "main"
  71. # ── updates 模式:步骤级事件(thinking / executing)──
  72. if chunk["type"] == "updates":
  73. for node_name in chunk["data"]:
  74. if node_name == "model":
  75. # 代理正在思考/推理
  76. yield f"data: {json.dumps({'type': 'thinking', 'source': source, 'node': node_name, 'cn_agent': agent_cn_name, 'message': '正在分析您的问题...'}, ensure_ascii=False)}\n\n"
  77. elif node_name == "tools":
  78. # 代理正在执行工具调用,提取工具名称
  79. tools_data = chunk["data"].get(node_name, {})
  80. tool_names = []
  81. for msg in tools_data.get("messages", []):
  82. if hasattr(msg, "name"):
  83. tool_names.append(msg.name)
  84. elif isinstance(msg, dict) and msg.get("name"):
  85. tool_names.append(msg["name"])
  86. # 生成中文描述
  87. cn_names = [_cn_tool_desc(t) for t in tool_names] if tool_names else ["工具调用"]
  88. msg_text = f"正在{'、'.join(cn_names)}..."
  89. yield f"data: {json.dumps({'type': 'executing', 'source': source, 'cn_agent': agent_cn_name, 'tools': tool_names, 'cn_tools': cn_names, 'message': msg_text}, ensure_ascii=False)}\n\n"
  90. # ── messages 模式:token 级事件(token / tool_call / tool_result / updated_todo_list)──
  91. elif chunk["type"] == "messages":
  92. token_data = chunk["data"]
  93. if isinstance(token_data, (list, tuple)) and len(token_data) >= 1:
  94. msg_obj, _metadata = token_data[0], token_data[1] if len(token_data) > 1 else {}
  95. else:
  96. msg_obj = token_data
  97. # 检测工具调用(tool_call_chunks 在流式传输中逐步到达)
  98. if hasattr(msg_obj, "tool_call_chunks") and msg_obj.tool_call_chunks:
  99. for tc in msg_obj.tool_call_chunks:
  100. if tc.get("name"):
  101. cn = _cn_tool_desc(tc["name"])
  102. yield f"data: {json.dumps({'type': 'tool_call', 'source': source, 'cn_agent': agent_cn_name, 'tool': tc['name'], 'cn_tool': cn, 'message': f'调用工具:{cn}'}, ensure_ascii=False)}\n\n"
  103. # 检测工具结果
  104. is_tool_msg = hasattr(msg_obj, "type") and msg_obj.type == "tool"
  105. if is_tool_msg:
  106. tool_name = getattr(msg_obj, "name", "unknown")
  107. if tool_name == "write_todos":
  108. # write_todos 特殊处理:提取 JSON 并发送 updated_todo_list 事件
  109. tool_content = _extract_text_content(msg_obj) or ""
  110. todo_list = _parse_todo_list(tool_content)
  111. if todo_list:
  112. yield f"data: {json.dumps({'type': 'updated_todo_list', 'cn_agent': agent_cn_name, 'source': source, 'todos': todo_list, 'message': '任务进度已更新'}, ensure_ascii=False)}\n\n"
  113. else:
  114. # 解析失败时也至少发一个 tool_result 事件,并打印诊断日志
  115. print(f"[write_todos] 解析失败,原始内容: {tool_content[:300]}")
  116. yield f"data: {json.dumps({'type': 'tool_result', 'source': source, 'cn_agent': agent_cn_name, 'tool': tool_name, 'cn_tool': _cn_tool_desc(tool_name), 'message': '任务进度已更新'}, ensure_ascii=False)}\n\n"
  117. else:
  118. cn = _cn_tool_desc(tool_name)
  119. yield f"data: {json.dumps({'type': 'tool_result', 'source': source, 'cn_agent': agent_cn_name, 'tool': tool_name, 'cn_tool': cn, 'message': f'{cn} 完成'}, ensure_ascii=False)}\n\n"
  120. # 提取并流式输出文本内容(工具结果不当作 token 输出,仅输出 AI 生成的文本)
  121. if not is_tool_msg:
  122. # 先检查推理/思考内容(DeepSeek 等模型的 reasoning_content)
  123. reasoning = _extract_reasoning_content(msg_obj)
  124. if reasoning:
  125. if _DEBUG_REASONING:
  126. print(f"[reasoning] ✅ YIELD reasoning event ({len(reasoning)} chars)")
  127. yield f"data: {json.dumps({'type': 'reasoning', 'source': source, 'content': reasoning}, ensure_ascii=False)}\n\n"
  128. # 再提取常规文本内容
  129. content = _extract_text_content(msg_obj)
  130. if content and isinstance(content, str):
  131. full_response += content
  132. yield f"data: {json.dumps({'type': 'token', 'source': source, 'content': content}, ensure_ascii=False)}\n\n"
  133. # ── 提取 token 用量元数据(最后一个消息块通常携带 usage)──
  134. _capture_token_meta(msg_obj, _token_usage)
  135. # 保存助手回复(仅对话场景写库)
  136. if save_to_db and full_response.strip():
  137. cleaned = _clean_response(full_response)
  138. save_message(session_id, "assistant", cleaned)
  139. print(full_response)
  140. # ── 更新上下文用量 ──
  141. if save_to_db and _token_usage["prompt"] > 0:
  142. await capture_context_usage(
  143. session_id=session_id,
  144. prompt_tokens=_token_usage["prompt"],
  145. completion_tokens=_token_usage["completion"],
  146. current_message=user_message,
  147. )
  148. # 发送 agent_done + done 事件
  149. duration_ms = int((time.time() - start_time) * 1000)
  150. yield f"data: {json.dumps({'type': 'agent_done', 'agent': agent_cn_name, 'cn_agent': agent_cn_name, 'duration_ms': duration_ms, 'message': f'「{agent_cn_name}」完成({duration_ms}ms)'}, ensure_ascii=False)}\n\n"
  151. # 检查 LangGraph 中断状态(Human-in-the-Loop)
  152. interrupt_info = _check_interrupt(agent, config)
  153. if interrupt_info:
  154. yield f"data: {json.dumps({'type': 'interrupt', **interrupt_info}, ensure_ascii=False)}\n\n"
  155. # 中断时不发送 done 事件,等待用户审批后恢复
  156. return
  157. yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'duration_ms': duration_ms, 'message': f'回答完成({duration_ms}ms)'}, ensure_ascii=False)}\n\n"
  158. except Exception as e:
  159. traceback.print_exc()
  160. yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
  161. # ── 文本提取 / 解析 / 清理 ──
  162. def _extract_text_content(msg_obj) -> str | None:
  163. """从 LangChain 消息对象中提取文本内容(兼容 dict 和对象两种形式)。"""
  164. if isinstance(msg_obj, dict):
  165. return msg_obj.get("content")
  166. elif hasattr(msg_obj, "content"):
  167. raw = getattr(msg_obj, "content", None)
  168. if isinstance(raw, str):
  169. return raw
  170. elif isinstance(raw, list):
  171. # 多模态内容块:合并所有 text 类型的块
  172. parts = []
  173. for block in raw:
  174. if isinstance(block, dict) and block.get("type") == "text":
  175. parts.append(block.get("text", ""))
  176. elif hasattr(block, "type") and getattr(block, "type", "") == "text":
  177. parts.append(getattr(block, "text", ""))
  178. return "".join(parts) if parts else None
  179. return None
  180. # ── 调试开关:设为 True 时打印推理内容摘要(排查完毕后关闭)──
  181. _DEBUG_REASONING = True
  182. _debug_dump_done = False # 只 dump 第一个非空 AI chunk 的完整属性
  183. def _extract_reasoning_content(msg_obj) -> str | None:
  184. """从 LangChain 消息对象中提取推理/思考内容(DeepSeek 等模型的 reasoning_content)。
  185. 支持三种来源(按优先级):
  186. 1. msg_obj.additional_kwargs["reasoning_content"](OpenAI 兼容流式 delta)
  187. 2. msg_obj.reasoning_content(LangChain 直接属性)
  188. 3. content 中的 thinking 类型块
  189. Returns:
  190. 推理文本字符串,无推理内容时返回 None
  191. """
  192. global _debug_dump_done
  193. msg_type = getattr(msg_obj, "type", "?")
  194. is_tool = (msg_type == "tool")
  195. content_raw = getattr(msg_obj, "content", "")
  196. # ── 调试:dump 第一个非空非工具 chunk 的所有属性 ──
  197. if _DEBUG_REASONING and not _debug_dump_done and not is_tool and content_raw:
  198. _debug_dump_done = True
  199. print(f"\n[reasoning-dump] === 第一个非空 AI chunk 完整属性 ===")
  200. print(f" type: {type(msg_obj).__name__}")
  201. # 打印所有属性(包括私有)
  202. for attr in sorted(dir(msg_obj)):
  203. if attr.startswith('_') and not attr.startswith('__'):
  204. continue
  205. try:
  206. val = getattr(msg_obj, attr)
  207. if callable(val):
  208. continue
  209. s = repr(val)
  210. if len(s) > 400:
  211. s = s[:400] + f"... (total {len(s)} chars)"
  212. print(f" {attr}: {s}")
  213. except Exception as e:
  214. print(f" {attr}: <error: {e}>")
  215. # 特别检查 additional_kwargs
  216. ak = getattr(msg_obj, "additional_kwargs", {})
  217. if isinstance(ak, dict) and ak:
  218. print(f" >>> additional_kwargs has keys: {list(ak.keys())}")
  219. for k, v in ak.items():
  220. sv = repr(v)
  221. if len(sv) > 500:
  222. sv = sv[:500] + f"... ({len(sv)} total)"
  223. print(f" >>> [{k}]: {sv}")
  224. else:
  225. print(f" >>> additional_kwargs: EMPTY or not dict (type={type(ak).__name__})")
  226. # 检查 response_metadata
  227. rm = getattr(msg_obj, "response_metadata", {})
  228. if isinstance(rm, dict) and rm:
  229. print(f" >>> response_metadata keys: {list(rm.keys())}")
  230. for k, v in rm.items():
  231. sv = repr(v)
  232. if len(sv) > 300:
  233. sv = sv[:300] + f"..."
  234. print(f" >>> [{k}]: {sv}")
  235. print(f"[reasoning-dump] === dump 完毕 ===\n")
  236. # 方式 1:additional_kwargs 中的 reasoning_content(最常见)
  237. if hasattr(msg_obj, "additional_kwargs") and isinstance(msg_obj.additional_kwargs, dict):
  238. reasoning = msg_obj.additional_kwargs.get("reasoning_content", "")
  239. if reasoning:
  240. if _DEBUG_REASONING:
  241. print(f"[reasoning] ✅ additional_kwargs ({len(reasoning)} chars): {reasoning[:120]}...")
  242. return reasoning
  243. # 方式 2:直接属性 reasoning_content
  244. reasoning = getattr(msg_obj, "reasoning_content", None)
  245. if reasoning:
  246. if _DEBUG_REASONING:
  247. print(f"[reasoning] ✅ direct attr ({len(reasoning)} chars): {reasoning[:120]}...")
  248. return reasoning
  249. # 方式 3:content 为 list 时,提取 thinking 类型的块
  250. if hasattr(msg_obj, "content"):
  251. raw = getattr(msg_obj, "content", None)
  252. if isinstance(raw, list):
  253. parts = []
  254. for block in raw:
  255. if isinstance(block, dict) and block.get("type") == "thinking":
  256. parts.append(block.get("thinking", ""))
  257. elif hasattr(block, "type") and getattr(block, "type", "") == "thinking":
  258. parts.append(getattr(block, "thinking", ""))
  259. if parts:
  260. result = "".join(parts)
  261. if _DEBUG_REASONING:
  262. print(f"[reasoning] ✅ content blocks ({len(result)} chars): {result[:120]}...")
  263. return result
  264. return None
  265. def _capture_token_meta(msg_obj, usage_ref: dict):
  266. """从 LangChain 消息对象中提取 LLM token 用量元数据。
  267. 优先读取 usage_metadata(langchain ≥0.3),
  268. 其次读取 response_metadata.usage(OpenAI 兼容格式)。
  269. 结果写入 usage_ref dict(原地修改)。
  270. """
  271. # 方式 1:usage_metadata(langchain 标准字段)
  272. um = getattr(msg_obj, "usage_metadata", None)
  273. if um and isinstance(um, dict):
  274. inp = um.get("input_tokens", 0)
  275. out = um.get("output_tokens", 0)
  276. if inp or out:
  277. usage_ref["prompt"] = inp
  278. usage_ref["completion"] = out
  279. return
  280. # 方式 2:response_metadata.usage(OpenAI / DeepSeek 兼容)
  281. rm = getattr(msg_obj, "response_metadata", None)
  282. if rm and isinstance(rm, dict):
  283. usage = rm.get("usage", {}) or rm.get("token_usage", {})
  284. if isinstance(usage, dict):
  285. inp = usage.get("prompt_tokens", 0)
  286. out = usage.get("completion_tokens", 0)
  287. if inp or out:
  288. usage_ref["prompt"] = inp
  289. usage_ref["completion"] = out
  290. return
  291. def _parse_todo_list(text: str) -> list | None:
  292. """从 write_todos 输出中提取 todo 列表。
  293. write_todos 输出格式: "Updated todo list to [{'content': '...', 'status': '...'}, ...]"
  294. 返回 JSON-serializable list of dicts,失败返回 None。
  295. """
  296. import ast
  297. # 贪婪匹配最外层 [...](内容中的 [ ] 在字符串字面量内,ast.literal_eval 可正确处理)
  298. match = re.search(r"\[.*\]", text)
  299. if not match:
  300. return None
  301. try:
  302. python_list = ast.literal_eval(match.group())
  303. if isinstance(python_list, list):
  304. return python_list
  305. except (ValueError, SyntaxError, TypeError):
  306. pass
  307. return None
  308. def _clean_response(text: str) -> str:
  309. """清理 Agent 回复中的格式噪音"""
  310. # 移除 ANSI 转义序列
  311. text = re.sub(r'\x1b\[[0-9;]*m', '', text)
  312. text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
  313. text = re.sub(r'\x1b\][^\x07]*\x07', '', text)
  314. # 移除工具调用残留(大括号 JSON 块如果独立成行则移除)
  315. text = re.sub(r'^\s*\{[^}]*\}\s*$', '', text, flags=re.MULTILINE)
  316. return text.strip()
  317. # ── LangGraph Human-in-the-Loop 中断处理 ──
  318. def _check_interrupt(agent, config: dict) -> dict | None:
  319. """
  320. 检查 LangGraph 状态是否被中断。
  321. 支持两种中断检测:
  322. 1. 程序化中断(节点内调用 interrupt())→ state.interrupts 非空
  323. 2. interrupt_before / interrupt_after 暂停 → state.next 非空
  324. 当中断发生时返回中断信息 dict,否则返回 None。
  325. 返回格式:
  326. {"node": "tools", "message": "智能体准备执行工具,等待审批...",
  327. "interrupts": [...], "plan": "计划文本(程序化中断时)"}
  328. """
  329. try:
  330. state = agent.get_state(config)
  331. except Exception:
  332. return None
  333. if state is None:
  334. return None
  335. # 1. 检测程序化中断(节点内调用 interrupt())
  336. interrupts = getattr(state, "interrupts", None)
  337. if not interrupts:
  338. values = getattr(state, "values", {}) or {}
  339. interrupts = values.get("__interrupt__", [])
  340. # 2. 检测 interrupt_before / interrupt_after 暂停
  341. # 图暂停时 state.next 非空(有待执行节点);完成时为空
  342. next_nodes = getattr(state, "next", None)
  343. is_paused_by_interrupt_config = bool(next_nodes) if next_nodes is not None else False
  344. if not interrupts and not is_paused_by_interrupt_config:
  345. return None
  346. # 提取中断节点名
  347. if interrupts:
  348. interrupt_data = interrupts[0] if interrupts else {}
  349. # 处理 interrupt() 返回值可能是 Interrupt 对象或 dict
  350. if hasattr(interrupt_data, "value"):
  351. interrupt_data = interrupt_data.value
  352. if isinstance(interrupt_data, dict):
  353. node = interrupt_data.get("type", "tools")
  354. plan = interrupt_data.get("plan", "")
  355. message = interrupt_data.get("message", "智能体暂停执行,等待您的审批...")
  356. else:
  357. node = str(next_nodes or "tools")
  358. plan = ""
  359. message = "智能体暂停执行,等待您的审批..."
  360. else:
  361. node = next_nodes
  362. plan = ""
  363. message = "智能体暂停执行,等待您的审批..."
  364. if isinstance(node, (list, tuple)):
  365. node = node[0] if node else "unknown"
  366. result = {
  367. "node": str(node),
  368. "message": message,
  369. "interrupts": [str(i) for i in interrupts] if interrupts else [],
  370. }
  371. if plan:
  372. result["plan"] = plan
  373. return result
  374. async def resume_stream(
  375. agent,
  376. session_id: str,
  377. thread_id: str,
  378. config: dict,
  379. agent_cn_name: str = "智能助手",
  380. action: str = "approve",
  381. interrupt_before: list | None = None,
  382. interrupt_after: list | None = None,
  383. ):
  384. """
  385. 恢复被中断的 LangGraph 流式执行。
  386. 参数:
  387. action: "approve" 或 "reject"
  388. Yields:
  389. SSE 事件字符串
  390. """
  391. if action == "reject":
  392. yield f"data: {json.dumps({'type': 'error', 'message': '用户拒绝了工具执行'}, ensure_ascii=False)}\n\n"
  393. yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'message': '已取消执行'}, ensure_ascii=False)}\n\n"
  394. return
  395. full_response = ""
  396. start_time = time.time()
  397. try:
  398. # 检测中断类型:程序化中断需用 Command(resume=...),配置式中断传 None
  399. state = agent.get_state(config)
  400. has_programmatic = bool(getattr(state, "interrupts", None)) if state else False
  401. stream_input = Command(resume={"action": action}) if has_programmatic else None
  402. # 恢复执行
  403. async for chunk in agent.astream(
  404. stream_input,
  405. stream_mode=["updates", "messages"],
  406. config=config,
  407. interrupt_before=interrupt_before,
  408. interrupt_after=interrupt_after,
  409. version="v2",
  410. ):
  411. is_subagent = any(s.startswith("tools:") for s in chunk.get("ns", []))
  412. if chunk["type"] == "updates":
  413. for node_name in chunk["data"]:
  414. if node_name == "model":
  415. yield f"data: {json.dumps({'type': 'thinking', 'source': 'main', 'cn_agent': agent_cn_name, 'message': '继续执行...'}, ensure_ascii=False)}\n\n"
  416. elif node_name == "tools":
  417. tools_data = chunk["data"].get(node_name, {})
  418. tool_names = []
  419. for msg in tools_data.get("messages", []):
  420. if hasattr(msg, "name"):
  421. tool_names.append(msg.name)
  422. elif isinstance(msg, dict) and msg.get("name"):
  423. tool_names.append(msg["name"])
  424. cn_names = [_cn_tool_desc(t) for t in tool_names] if tool_names else ["工具调用"]
  425. yield f"data: {json.dumps({'type': 'executing', 'source': 'main', 'cn_agent': agent_cn_name, 'tools': tool_names, 'cn_tools': cn_names, 'message': f"正在{'、'.join(cn_names)}..."}, ensure_ascii=False)}\n\n"
  426. elif chunk["type"] == "messages":
  427. token_data = chunk["data"]
  428. if isinstance(token_data, (list, tuple)) and len(token_data) >= 1:
  429. msg_obj = token_data[0]
  430. else:
  431. msg_obj = token_data
  432. # 工具结果
  433. is_tool_msg = hasattr(msg_obj, "type") and msg_obj.type == "tool"
  434. if is_tool_msg:
  435. tool_name = getattr(msg_obj, "name", "unknown")
  436. if tool_name != "write_todos":
  437. yield f"data: {json.dumps({'type': 'tool_result', 'source': 'main', 'cn_agent': agent_cn_name, 'tool': tool_name, 'cn_tool': _cn_tool_desc(tool_name), 'message': f'{_cn_tool_desc(tool_name)} 完成'}, ensure_ascii=False)}\n\n"
  438. # AI 文本
  439. if not is_tool_msg:
  440. # 先检查推理/思考内容(DeepSeek 等模型的 reasoning_content)
  441. reasoning = _extract_reasoning_content(msg_obj)
  442. if reasoning:
  443. if _DEBUG_REASONING:
  444. print(f"[reasoning] ✅ YIELD reasoning event resume ({len(reasoning)} chars)")
  445. yield f"data: {json.dumps({'type': 'reasoning', 'source': 'main', 'content': reasoning}, ensure_ascii=False)}\n\n"
  446. # 再提取常规文本内容
  447. content = _extract_text_content(msg_obj)
  448. if content and isinstance(content, str):
  449. full_response += content
  450. yield f"data: {json.dumps({'type': 'token', 'source': 'main', 'content': content}, ensure_ascii=False)}\n\n"
  451. # 保存助手回复
  452. if full_response.strip():
  453. cleaned = _clean_response(full_response)
  454. save_message(session_id, "assistant", cleaned)
  455. # 再次检查中断(可能有多次中断)
  456. interrupt_info = _check_interrupt(agent, config)
  457. if interrupt_info:
  458. yield f"data: {json.dumps({'type': 'interrupt', **interrupt_info}, ensure_ascii=False)}\n\n"
  459. return
  460. duration_ms = int((time.time() - start_time) * 1000)
  461. yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'duration_ms': duration_ms, 'message': f'回答完成({duration_ms}ms)'}, ensure_ascii=False)}\n\n"
  462. except Exception as e:
  463. traceback.print_exc()
  464. yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"