| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547 |
- # -*- coding: utf-8 -*-
- """
- SSE 核心工具函数 —— 流式事件生成器、文本提取、响应清理等通用辅助
- """
- import json
- import re
- import time
- import traceback
- from typing import AsyncGenerator
- from langgraph.types import Command # 程序化 interrupt() 恢复时需启用
- from tools.tool_names_cn import TOOL_NAME_CN as _TOOL_CN
- from db.chat_store import save_message, get_messages
- from tools.context_tracker import capture_context_usage
- # ── 工具名 → 中文描述映射 ──
- def _cn_tool_desc(tool_name: str) -> str:
- """将工具函数名映射为中文描述短语(用于 SSE message 字段)。
- 映射表统一维护在 tools/tool_names_cn.py 中,新增工具只需改那一处。
- """
- return _TOOL_CN.get(tool_name, tool_name)
- # ── 通用 SSE 流式事件生成器 ──
- async def sse_event_generator(
- agent,
- user_message: str,
- thread_id: str,
- session_id: str,
- config: dict,
- agent_cn_name: str = "智能助手",
- save_to_db: bool = True,
- original_user_message: str | None = None,
- interrupt_before: list | None = None,
- interrupt_after: list | None = None,
- ) -> AsyncGenerator[str, None]:
- """
- 通用 SSE 流式事件生成器。
- 使用 stream_mode=["updates", "messages"] 获取完整执行图景,
- 前端可根据 event type 区分:thinking / executing / tool_call / token / done / error。
- Args:
- save_to_db: 是否将消息保存到数据库。点选解读等非对话场景应设为 False。
- original_user_message: 用户的原始消息(不含系统注入的前缀)。
- 若提供,则 DB 保存此原始消息;user_message 仍作为 Agent 的输入。
- """
- # 保存用户消息(仅对话场景写库),优先使用原始消息
- if save_to_db:
- save_message(session_id, "user", original_user_message or user_message)
- # 用于收集完整回复
- full_response = ""
- start_time = time.time()
- _token_usage = {"prompt": 0, "completion": 0} # 从流中捕获的实际 token 用量
- try:
- # ── 发送 agent_start 事件,前端据此创建 Agent 卡片 ──
- 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"
- # 构建消息列表(包含历史消息 + 当前消息)
- if save_to_db:
- messages = [{"role": "user", "content": msg["content"]}
- for msg in get_messages(session_id, limit=50)]
- else:
- messages = []
- messages.append({"role": "user", "content": user_message})
- # 使用 agent.astream() 异步流式调用,多模式获取完整图景
- async for chunk in agent.astream(
- {"messages": messages},
- stream_mode=["updates", "messages"],
- config=config,
- interrupt_before=interrupt_before,
- interrupt_after=interrupt_after,
- version="v2",
- ):
- # 判断事件来源:主代理 vs 子代理
- is_subagent = any(s.startswith("tools:") for s in chunk.get("ns", []))
- source = "subagent" if is_subagent else "main"
- # ── updates 模式:步骤级事件(thinking / executing)──
- if chunk["type"] == "updates":
- for node_name in chunk["data"]:
- if node_name == "model":
- # 代理正在思考/推理
- yield f"data: {json.dumps({'type': 'thinking', 'source': source, 'node': node_name, 'cn_agent': agent_cn_name, 'message': '正在分析您的问题...'}, ensure_ascii=False)}\n\n"
- elif node_name == "tools":
- # 代理正在执行工具调用,提取工具名称
- tools_data = chunk["data"].get(node_name, {})
- tool_names = []
- for msg in tools_data.get("messages", []):
- if hasattr(msg, "name"):
- tool_names.append(msg.name)
- elif isinstance(msg, dict) and msg.get("name"):
- tool_names.append(msg["name"])
- # 生成中文描述
- cn_names = [_cn_tool_desc(t) for t in tool_names] if tool_names else ["工具调用"]
- msg_text = f"正在{'、'.join(cn_names)}..."
- 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"
- # ── messages 模式:token 级事件(token / tool_call / tool_result / updated_todo_list)──
- elif chunk["type"] == "messages":
- token_data = chunk["data"]
- if isinstance(token_data, (list, tuple)) and len(token_data) >= 1:
- msg_obj, _metadata = token_data[0], token_data[1] if len(token_data) > 1 else {}
- else:
- msg_obj = token_data
- # 检测工具调用(tool_call_chunks 在流式传输中逐步到达)
- if hasattr(msg_obj, "tool_call_chunks") and msg_obj.tool_call_chunks:
- for tc in msg_obj.tool_call_chunks:
- if tc.get("name"):
- cn = _cn_tool_desc(tc["name"])
- 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"
- # 检测工具结果
- is_tool_msg = hasattr(msg_obj, "type") and msg_obj.type == "tool"
- if is_tool_msg:
- tool_name = getattr(msg_obj, "name", "unknown")
- if tool_name == "write_todos":
- # write_todos 特殊处理:提取 JSON 并发送 updated_todo_list 事件
- tool_content = _extract_text_content(msg_obj) or ""
- todo_list = _parse_todo_list(tool_content)
- if todo_list:
- 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"
- else:
- # 解析失败时也至少发一个 tool_result 事件,并打印诊断日志
- print(f"[write_todos] 解析失败,原始内容: {tool_content[:300]}")
- 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"
- else:
- cn = _cn_tool_desc(tool_name)
- 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"
- # 提取并流式输出文本内容(工具结果不当作 token 输出,仅输出 AI 生成的文本)
- if not is_tool_msg:
- # 先检查推理/思考内容(DeepSeek 等模型的 reasoning_content)
- reasoning = _extract_reasoning_content(msg_obj)
- if reasoning:
- if _DEBUG_REASONING:
- print(f"[reasoning] ✅ YIELD reasoning event ({len(reasoning)} chars)")
- yield f"data: {json.dumps({'type': 'reasoning', 'source': source, 'content': reasoning}, ensure_ascii=False)}\n\n"
- # 再提取常规文本内容
- content = _extract_text_content(msg_obj)
- if content and isinstance(content, str):
- full_response += content
- yield f"data: {json.dumps({'type': 'token', 'source': source, 'content': content}, ensure_ascii=False)}\n\n"
- # ── 提取 token 用量元数据(最后一个消息块通常携带 usage)──
- _capture_token_meta(msg_obj, _token_usage)
- # 保存助手回复(仅对话场景写库)
- if save_to_db and full_response.strip():
- cleaned = _clean_response(full_response)
- save_message(session_id, "assistant", cleaned)
- print(full_response)
- # ── 更新上下文用量 ──
- if save_to_db and _token_usage["prompt"] > 0:
- await capture_context_usage(
- session_id=session_id,
- prompt_tokens=_token_usage["prompt"],
- completion_tokens=_token_usage["completion"],
- current_message=user_message,
- )
- # 发送 agent_done + done 事件
- duration_ms = int((time.time() - start_time) * 1000)
- 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"
- # 检查 LangGraph 中断状态(Human-in-the-Loop)
- interrupt_info = _check_interrupt(agent, config)
- if interrupt_info:
- yield f"data: {json.dumps({'type': 'interrupt', **interrupt_info}, ensure_ascii=False)}\n\n"
- # 中断时不发送 done 事件,等待用户审批后恢复
- return
- 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"
- except Exception as e:
- traceback.print_exc()
- yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
- # ── 文本提取 / 解析 / 清理 ──
- def _extract_text_content(msg_obj) -> str | None:
- """从 LangChain 消息对象中提取文本内容(兼容 dict 和对象两种形式)。"""
- if isinstance(msg_obj, dict):
- return msg_obj.get("content")
- elif hasattr(msg_obj, "content"):
- raw = getattr(msg_obj, "content", None)
- if isinstance(raw, str):
- return raw
- elif isinstance(raw, list):
- # 多模态内容块:合并所有 text 类型的块
- parts = []
- for block in raw:
- if isinstance(block, dict) and block.get("type") == "text":
- parts.append(block.get("text", ""))
- elif hasattr(block, "type") and getattr(block, "type", "") == "text":
- parts.append(getattr(block, "text", ""))
- return "".join(parts) if parts else None
- return None
- # ── 调试开关:设为 True 时打印推理内容摘要(排查完毕后关闭)──
- _DEBUG_REASONING = True
- _debug_dump_done = False # 只 dump 第一个非空 AI chunk 的完整属性
- def _extract_reasoning_content(msg_obj) -> str | None:
- """从 LangChain 消息对象中提取推理/思考内容(DeepSeek 等模型的 reasoning_content)。
- 支持三种来源(按优先级):
- 1. msg_obj.additional_kwargs["reasoning_content"](OpenAI 兼容流式 delta)
- 2. msg_obj.reasoning_content(LangChain 直接属性)
- 3. content 中的 thinking 类型块
- Returns:
- 推理文本字符串,无推理内容时返回 None
- """
- global _debug_dump_done
- msg_type = getattr(msg_obj, "type", "?")
- is_tool = (msg_type == "tool")
- content_raw = getattr(msg_obj, "content", "")
- # ── 调试:dump 第一个非空非工具 chunk 的所有属性 ──
- if _DEBUG_REASONING and not _debug_dump_done and not is_tool and content_raw:
- _debug_dump_done = True
- print(f"\n[reasoning-dump] === 第一个非空 AI chunk 完整属性 ===")
- print(f" type: {type(msg_obj).__name__}")
- # 打印所有属性(包括私有)
- for attr in sorted(dir(msg_obj)):
- if attr.startswith('_') and not attr.startswith('__'):
- continue
- try:
- val = getattr(msg_obj, attr)
- if callable(val):
- continue
- s = repr(val)
- if len(s) > 400:
- s = s[:400] + f"... (total {len(s)} chars)"
- print(f" {attr}: {s}")
- except Exception as e:
- print(f" {attr}: <error: {e}>")
- # 特别检查 additional_kwargs
- ak = getattr(msg_obj, "additional_kwargs", {})
- if isinstance(ak, dict) and ak:
- print(f" >>> additional_kwargs has keys: {list(ak.keys())}")
- for k, v in ak.items():
- sv = repr(v)
- if len(sv) > 500:
- sv = sv[:500] + f"... ({len(sv)} total)"
- print(f" >>> [{k}]: {sv}")
- else:
- print(f" >>> additional_kwargs: EMPTY or not dict (type={type(ak).__name__})")
- # 检查 response_metadata
- rm = getattr(msg_obj, "response_metadata", {})
- if isinstance(rm, dict) and rm:
- print(f" >>> response_metadata keys: {list(rm.keys())}")
- for k, v in rm.items():
- sv = repr(v)
- if len(sv) > 300:
- sv = sv[:300] + f"..."
- print(f" >>> [{k}]: {sv}")
- print(f"[reasoning-dump] === dump 完毕 ===\n")
- # 方式 1:additional_kwargs 中的 reasoning_content(最常见)
- if hasattr(msg_obj, "additional_kwargs") and isinstance(msg_obj.additional_kwargs, dict):
- reasoning = msg_obj.additional_kwargs.get("reasoning_content", "")
- if reasoning:
- if _DEBUG_REASONING:
- print(f"[reasoning] ✅ additional_kwargs ({len(reasoning)} chars): {reasoning[:120]}...")
- return reasoning
- # 方式 2:直接属性 reasoning_content
- reasoning = getattr(msg_obj, "reasoning_content", None)
- if reasoning:
- if _DEBUG_REASONING:
- print(f"[reasoning] ✅ direct attr ({len(reasoning)} chars): {reasoning[:120]}...")
- return reasoning
- # 方式 3:content 为 list 时,提取 thinking 类型的块
- if hasattr(msg_obj, "content"):
- raw = getattr(msg_obj, "content", None)
- if isinstance(raw, list):
- parts = []
- for block in raw:
- if isinstance(block, dict) and block.get("type") == "thinking":
- parts.append(block.get("thinking", ""))
- elif hasattr(block, "type") and getattr(block, "type", "") == "thinking":
- parts.append(getattr(block, "thinking", ""))
- if parts:
- result = "".join(parts)
- if _DEBUG_REASONING:
- print(f"[reasoning] ✅ content blocks ({len(result)} chars): {result[:120]}...")
- return result
- return None
- def _capture_token_meta(msg_obj, usage_ref: dict):
- """从 LangChain 消息对象中提取 LLM token 用量元数据。
- 优先读取 usage_metadata(langchain ≥0.3),
- 其次读取 response_metadata.usage(OpenAI 兼容格式)。
- 结果写入 usage_ref dict(原地修改)。
- """
- # 方式 1:usage_metadata(langchain 标准字段)
- um = getattr(msg_obj, "usage_metadata", None)
- if um and isinstance(um, dict):
- inp = um.get("input_tokens", 0)
- out = um.get("output_tokens", 0)
- if inp or out:
- usage_ref["prompt"] = inp
- usage_ref["completion"] = out
- return
- # 方式 2:response_metadata.usage(OpenAI / DeepSeek 兼容)
- rm = getattr(msg_obj, "response_metadata", None)
- if rm and isinstance(rm, dict):
- usage = rm.get("usage", {}) or rm.get("token_usage", {})
- if isinstance(usage, dict):
- inp = usage.get("prompt_tokens", 0)
- out = usage.get("completion_tokens", 0)
- if inp or out:
- usage_ref["prompt"] = inp
- usage_ref["completion"] = out
- return
- def _parse_todo_list(text: str) -> list | None:
- """从 write_todos 输出中提取 todo 列表。
- write_todos 输出格式: "Updated todo list to [{'content': '...', 'status': '...'}, ...]"
- 返回 JSON-serializable list of dicts,失败返回 None。
- """
- import ast
- # 贪婪匹配最外层 [...](内容中的 [ ] 在字符串字面量内,ast.literal_eval 可正确处理)
- match = re.search(r"\[.*\]", text)
- if not match:
- return None
- try:
- python_list = ast.literal_eval(match.group())
- if isinstance(python_list, list):
- return python_list
- except (ValueError, SyntaxError, TypeError):
- pass
- return None
- def _clean_response(text: str) -> str:
- """清理 Agent 回复中的格式噪音"""
- # 移除 ANSI 转义序列
- text = re.sub(r'\x1b\[[0-9;]*m', '', text)
- text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
- text = re.sub(r'\x1b\][^\x07]*\x07', '', text)
- # 移除工具调用残留(大括号 JSON 块如果独立成行则移除)
- text = re.sub(r'^\s*\{[^}]*\}\s*$', '', text, flags=re.MULTILINE)
- return text.strip()
- # ── LangGraph Human-in-the-Loop 中断处理 ──
- def _check_interrupt(agent, config: dict) -> dict | None:
- """
- 检查 LangGraph 状态是否被中断。
- 支持两种中断检测:
- 1. 程序化中断(节点内调用 interrupt())→ state.interrupts 非空
- 2. interrupt_before / interrupt_after 暂停 → state.next 非空
- 当中断发生时返回中断信息 dict,否则返回 None。
- 返回格式:
- {"node": "tools", "message": "智能体准备执行工具,等待审批...",
- "interrupts": [...], "plan": "计划文本(程序化中断时)"}
- """
- try:
- state = agent.get_state(config)
- except Exception:
- return None
- if state is None:
- return None
- # 1. 检测程序化中断(节点内调用 interrupt())
- interrupts = getattr(state, "interrupts", None)
- if not interrupts:
- values = getattr(state, "values", {}) or {}
- interrupts = values.get("__interrupt__", [])
- # 2. 检测 interrupt_before / interrupt_after 暂停
- # 图暂停时 state.next 非空(有待执行节点);完成时为空
- next_nodes = getattr(state, "next", None)
- is_paused_by_interrupt_config = bool(next_nodes) if next_nodes is not None else False
- if not interrupts and not is_paused_by_interrupt_config:
- return None
- # 提取中断节点名
- if interrupts:
- interrupt_data = interrupts[0] if interrupts else {}
- # 处理 interrupt() 返回值可能是 Interrupt 对象或 dict
- if hasattr(interrupt_data, "value"):
- interrupt_data = interrupt_data.value
- if isinstance(interrupt_data, dict):
- node = interrupt_data.get("type", "tools")
- plan = interrupt_data.get("plan", "")
- message = interrupt_data.get("message", "智能体暂停执行,等待您的审批...")
- else:
- node = str(next_nodes or "tools")
- plan = ""
- message = "智能体暂停执行,等待您的审批..."
- else:
- node = next_nodes
- plan = ""
- message = "智能体暂停执行,等待您的审批..."
- if isinstance(node, (list, tuple)):
- node = node[0] if node else "unknown"
- result = {
- "node": str(node),
- "message": message,
- "interrupts": [str(i) for i in interrupts] if interrupts else [],
- }
- if plan:
- result["plan"] = plan
- return result
- async def resume_stream(
- agent,
- session_id: str,
- thread_id: str,
- config: dict,
- agent_cn_name: str = "智能助手",
- action: str = "approve",
- interrupt_before: list | None = None,
- interrupt_after: list | None = None,
- ):
- """
- 恢复被中断的 LangGraph 流式执行。
- 参数:
- action: "approve" 或 "reject"
- Yields:
- SSE 事件字符串
- """
- if action == "reject":
- yield f"data: {json.dumps({'type': 'error', 'message': '用户拒绝了工具执行'}, ensure_ascii=False)}\n\n"
- yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'message': '已取消执行'}, ensure_ascii=False)}\n\n"
- return
- full_response = ""
- start_time = time.time()
- try:
- # 检测中断类型:程序化中断需用 Command(resume=...),配置式中断传 None
- state = agent.get_state(config)
- has_programmatic = bool(getattr(state, "interrupts", None)) if state else False
- stream_input = Command(resume={"action": action}) if has_programmatic else None
- # 恢复执行
- async for chunk in agent.astream(
- stream_input,
- stream_mode=["updates", "messages"],
- config=config,
- interrupt_before=interrupt_before,
- interrupt_after=interrupt_after,
- version="v2",
- ):
- is_subagent = any(s.startswith("tools:") for s in chunk.get("ns", []))
- if chunk["type"] == "updates":
- for node_name in chunk["data"]:
- if node_name == "model":
- yield f"data: {json.dumps({'type': 'thinking', 'source': 'main', 'cn_agent': agent_cn_name, 'message': '继续执行...'}, ensure_ascii=False)}\n\n"
- elif node_name == "tools":
- tools_data = chunk["data"].get(node_name, {})
- tool_names = []
- for msg in tools_data.get("messages", []):
- if hasattr(msg, "name"):
- tool_names.append(msg.name)
- elif isinstance(msg, dict) and msg.get("name"):
- tool_names.append(msg["name"])
- cn_names = [_cn_tool_desc(t) for t in tool_names] if tool_names else ["工具调用"]
- 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"
- elif chunk["type"] == "messages":
- token_data = chunk["data"]
- if isinstance(token_data, (list, tuple)) and len(token_data) >= 1:
- msg_obj = token_data[0]
- else:
- msg_obj = token_data
- # 工具结果
- is_tool_msg = hasattr(msg_obj, "type") and msg_obj.type == "tool"
- if is_tool_msg:
- tool_name = getattr(msg_obj, "name", "unknown")
- if tool_name != "write_todos":
- 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"
- # AI 文本
- if not is_tool_msg:
- # 先检查推理/思考内容(DeepSeek 等模型的 reasoning_content)
- reasoning = _extract_reasoning_content(msg_obj)
- if reasoning:
- if _DEBUG_REASONING:
- print(f"[reasoning] ✅ YIELD reasoning event resume ({len(reasoning)} chars)")
- yield f"data: {json.dumps({'type': 'reasoning', 'source': 'main', 'content': reasoning}, ensure_ascii=False)}\n\n"
- # 再提取常规文本内容
- content = _extract_text_content(msg_obj)
- if content and isinstance(content, str):
- full_response += content
- yield f"data: {json.dumps({'type': 'token', 'source': 'main', 'content': content}, ensure_ascii=False)}\n\n"
- # 保存助手回复
- if full_response.strip():
- cleaned = _clean_response(full_response)
- save_message(session_id, "assistant", cleaned)
- # 再次检查中断(可能有多次中断)
- interrupt_info = _check_interrupt(agent, config)
- if interrupt_info:
- yield f"data: {json.dumps({'type': 'interrupt', **interrupt_info}, ensure_ascii=False)}\n\n"
- return
- duration_ms = int((time.time() - start_time) * 1000)
- 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"
- except Exception as e:
- traceback.print_exc()
- yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|