"""POST /api/chat — SSE streaming chat with Agent.""" import json import asyncio import os import traceback from pathlib import Path from typing import AsyncGenerator from fastapi import APIRouter from pydantic import BaseModel from sse_starlette.sse import EventSourceResponse from graph.agent import agent_manager from graph.session_manager import session_manager from graph.chat_model_options import chat_model_options router = APIRouter() class ChatRequest(BaseModel): message: str session_id: str = "default" stream: bool = True async def _generate_title(session_id: str) -> str | None: """Generate a title for a session using DeepSeek. Returns title or None.""" try: messages = session_manager.load_session(session_id) first_user = "" first_assistant = "" for msg in messages: if msg["role"] == "user" and not first_user: first_user = msg["content"][:200] elif msg["role"] == "assistant" and not first_assistant: first_assistant = msg["content"][:200] if first_user and first_assistant: break if not first_user: return None from langchain_deepseek import ChatDeepSeek from langchain_core.messages import HumanMessage as HM llm = ChatDeepSeek( model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), api_key=os.getenv("DEEPSEEK_API_KEY"), api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), temperature=0.3, **chat_model_options(), ) prompt = ( f"根据以下对话内容,生成一个不超过10个字的中文标题,只输出标题文本,不要加引号或标点。\n\n" f"用户: {first_user}\n" f"助手: {first_assistant}" ) result = await llm.ainvoke([HM(content=prompt)]) title = result.content.strip().strip('"\'""''')[:20] session_manager.update_title(session_id, title) return title except Exception: traceback.print_exc() return None class EmptyChatResponse(RuntimeError): pass async def bounded_events(iterator, idle_seconds=75, total_seconds=180): """Bound stalls and total execution; never replay possible tool side effects.""" deadline = asyncio.get_running_loop().time() + total_seconds try: while True: remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: raise TimeoutError() try: item = await asyncio.wait_for(anext(iterator), timeout=min(idle_seconds, remaining)) except StopAsyncIteration: break yield item finally: await iterator.aclose() async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict, None]: """Generate SSE events from agent stream. Tracks multiple response segments — each time the agent finishes tool calls and starts generating new text, a new_response event is emitted and a new segment begins. Each segment is saved as a separate assistant message in the session history. """ segments: list[dict] = [] current_segment: dict = {"content": "", "tool_calls": []} saved = False completed = False try: # Use merged history for agent context (combines consecutive assistant msgs) history = session_manager.load_session_for_agent(session_id) is_first_message = len(history) == 0 # Per-segment tracking segments: list[dict] = [] # finalized segments current_segment: dict = {"content": "", "tool_calls": []} async for event in bounded_events(agent_manager.astream(message, history, session_id)): event_type = event.get("type", "unknown") if event_type == "retrieval": yield { "event": "retrieval", "data": json.dumps( {"query": event["query"], "results": event["results"]}, ensure_ascii=False, ), } elif event_type == "token": current_segment["content"] += event["content"] yield { "event": "token", "data": json.dumps({"content": event["content"]}, ensure_ascii=False), } elif event_type == "new_response": # Finalize current segment and start a new one segments.append(current_segment) current_segment = {"content": "", "tool_calls": []} yield { "event": "new_response", "data": json.dumps({}, ensure_ascii=False), } elif event_type == "tool_start": current_segment["tool_calls"].append({ "tool": event["tool"], "input": event.get("input", ""), }) yield { "event": "tool_start", "data": json.dumps( {"tool": event["tool"], "input": event["input"]}, ensure_ascii=False, ), } elif event_type == "tool_end": # Match with current segment's tool_calls to add output for tc in reversed(current_segment["tool_calls"]): if tc["tool"] == event["tool"] and "output" not in tc: tc["output"] = event["output"] break yield { "event": "tool_end", "data": json.dumps( {"tool": event["tool"], "output": event["output"]}, ensure_ascii=False, ), } elif event_type == "done": if not str(event.get("content") or "").strip(): raise EmptyChatResponse() # Some providers deliver final content without token events. if not current_segment["content"] and not any(s["content"] for s in segments): current_segment["content"] = event["content"] yield {"event":"token", "data":json.dumps({"content":event["content"]}, ensure_ascii=False)} # Finalize the last segment segments.append(current_segment) # Save user message once session_manager.save_message(session_id, "user", message) # Save each segment as a separate assistant message for seg in segments: if not seg["content"].strip() and not seg["tool_calls"]: continue tc = seg["tool_calls"] if seg["tool_calls"] else None session_manager.save_message( session_id, "assistant", seg["content"], tool_calls=tc ) saved = True completed = True yield { "event": "done", "data": json.dumps( {"content": event["content"], "session_id": session_id}, ensure_ascii=False, ), } # Auto-generate title on first conversation if is_first_message: try: title = await asyncio.wait_for(_generate_title(session_id), timeout=8) except TimeoutError: title = None if title: yield { "event": "title", "data": json.dumps( {"session_id": session_id, "title": title}, ensure_ascii=False, ), } if not completed: raise ConnectionError("stream ended before done") except Exception as e: is_timeout = isinstance(e, TimeoutError) or type(e).__name__ in ("APITimeoutError", "ReadTimeout", "ConnectTimeout") code = "empty_response" if isinstance(e, EmptyChatResponse) else "timeout" if is_timeout else "chat_failed" message_text = { "empty_response":"模型未返回回答正文,可能只返回了推理字段。请检查聊天模型兼容配置后重试。", "timeout":"模型响应超时,已停止本次等待。请检查服务后重试;不会自动重复执行工具。", "chat_failed":"聊天未完成,请检查当前后端、模型连接或访问凭据后重试。已接收的内容会保留。", }[code] print(f"Chat failed: {type(e).__name__}") if not saved: session_manager.save_message(session_id, "user", message) for seg in segments: if seg["content"] or seg["tool_calls"]: session_manager.save_message(session_id, "assistant", seg["content"], tool_calls=seg["tool_calls"] or None) session_manager.save_message(session_id, "assistant", current_segment["content"] + "\n\n【未完成】" + message_text, tool_calls=current_segment["tool_calls"] or None) yield { "event": "error", "data": json.dumps({"error": message_text, "code": code}, ensure_ascii=False), } @router.post("/chat") async def chat(request: ChatRequest): if request.stream: return EventSourceResponse( event_generator(request.message, request.session_id) ) # Non-streaming fallback result = await agent_manager.ainvoke(request.message, request.session_id) return {"reply": result} @router.get("/chat/raw-traces") async def get_raw_traces(session_id: str = "default", limit: int = 20): """获取指定 session 的原始 LLM 调用报文 Args: session_id: 会话 ID limit: 返回最近 N 条记录 (默认 20) Returns: {"traces": [...]} 原始报文列表,按时间倒序 """ try: from graph.raw_trace import load_raw_traces # 从 agent_manager 获取 raw_logs_dir raw_logs_dir = agent_manager._raw_logs_dir if not raw_logs_dir: return {"traces": []} traces = load_raw_traces(session_id, raw_logs_dir, limit=limit) return {"traces": traces} except Exception as e: traceback.print_exc() return {"traces": [], "error": str(e)} @router.get("/chat/trajectory") async def get_trajectory(session_id: str = "default"): """获取会话完整交互轨迹 - 包含完整的 human/assistant/tool 消息流 Args: session_id: 会话 ID Returns: { "session_id": "...", "messages": [ {"role": "human", "content": "...", "timestamp": ...}, {"role": "assistant", "content": "...", "timestamp": ..., "tool_calls": [...]}, {"role": "tool", "name": "...", "content": "...", "timestamp": ...}, {"role": "assistant", "content": "...", "timestamp": ...} ], "steps": [...] // 保留旧格式用于向后兼容 } """ try: import json from graph.raw_trace import load_raw_traces raw_logs_dir = agent_manager._raw_logs_dir if not raw_logs_dir: # 无 raw_logs_dir,返回空 return {"session_id": session_id, "messages": [], "steps": []} # 优先读取完整消息流日志文件 msgs_log_file = raw_logs_dir / f"{session_id}.msgs.jsonl" messages = [] if msgs_log_file.exists(): # 从 msgs.jsonl 读取完整消息流(包含 tool 消息) with open(msgs_log_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: try: msg = json.loads(line) messages.append(msg) except json.JSONDecodeError: continue else: # 回退到旧逻辑:从 session 文件读取(无 tool 消息) session_messages = session_manager.load_session(session_id) for msg in session_messages: messages.append({ "role": msg["role"], "content": msg["content"], "timestamp": None, }) # 构建旧格式 steps(向后兼容) steps = [] traces = load_raw_traces(session_id, raw_logs_dir, limit=1000) traces.sort(key=lambda x: x.get("timestamp_start", 0)) # 按 user/assistant 消息构建 steps(忽略 tool 消息) session_messages = session_manager.load_session(session_id) assistant_count = sum(1 for m in session_messages if m["role"] == "assistant") for msg_idx, msg in enumerate(session_messages): step = { "role": msg["role"], "content": msg["content"], "timestamp": None, } if msg["role"] == "assistant": llm_calls = [] assistant_seq = sum(1 for m in session_messages[:msg_idx+1] if m["role"] == "assistant") traces_per_assistant = len(traces) / max(assistant_count, 1) start_trace = int((assistant_seq - 1) * traces_per_assistant) end_trace = int(assistant_seq * traces_per_assistant) for i in range(start_trace, min(end_trace, len(traces))): trace = traces[i] llm_call = { "model": trace.get("request", {}).get("model", "unknown"), "finish_reason": trace.get("finish_reason", "unknown"), "request_messages": trace.get("request", {}).get("messages", []), "response_text": "", "tool_calls_list": [], "usage": trace.get("usage", {}), "duration_ms": trace.get("duration_ms", 0), } response_gens = trace.get("response", {}).get("generations", []) if response_gens: llm_call["response_text"] = response_gens[0].get("text", "") msg_data = response_gens[0].get("message", {}) if isinstance(msg_data, dict) and "tool_calls" in msg_data: tool_calls = msg_data["tool_calls"] if tool_calls: llm_call["tool_calls_list"] = [ { "name": tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", ""), "args": tc.get("args", {}) if isinstance(tc, dict) else getattr(tc, "args", {}), } for tc in tool_calls ] llm_calls.append(llm_call) if step["timestamp"] is None: step["timestamp"] = trace.get("timestamp_start") step["llm_calls"] = llm_calls steps.append(step) return { "session_id": session_id, "messages": messages, # 新格式:完整消息流(含 tool) "steps": steps, # 旧格式:向后兼容 } except Exception as e: traceback.print_exc() return {"session_id": session_id, "messages": [], "steps": [], "error": str(e)}