chat_routes.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. """对话路由。
  2. ``POST /api/chat`` 是主 agent 的**唯一入口**,2.0 逐字保持 1.5 的对外契约
  3. (multipart 入参、SSE 帧格式、thread_id ≡ session_id、错误码语义),这样 1.5
  4. 的前端不用改一行就能连上。
  5. 与 1.5 的差异有三处:
  6. 1. **不做鉴权**(实现层):1.5 用 ``Depends(get_current_user)`` 校验
  7. ``x-access-token`` 并解析出用户;2.0 直接取 multipart 的可选字段
  8. ``user_name``(缺省 ``default``)。风险是客户端可自报身份,仅适用于
  9. 内网/开发期——上线前把 ``_resolve_user`` 换成真实的 ``Depends`` 即可,
  10. 其余代码不用动。
  11. 2. **没有意图识别**(实现层):1.5 曾经按意图分流到不同的 agent,后来也
  12. 收敛成了单一 dialog agent。2.0 直接就是单一 agent。
  13. 3. **删掉了 ``POST /api/chat/only``**(契约层,唯一一处收缩):1.5 的这个
  14. 端点与 ``/chat`` 除了流水上的 ``kind`` 标签外**行为完全相同**,而那个标签
  15. 没有任何消费方(不聚合、不分账、不影响额度)。1.5 自己也为这份重复付过
  16. 代价——见 AGENTS.md §六「为什么没有 /chat/only」。1.5 前端从未调用过它。
  17. 若将来确有按渠道分账的需求,应当**先把聚合/额度按渠道拆开**,再引入入口,
  18. 而不是先立一个行为相同的端点。
  19. 保留 ``/chat`` 之外的另两个入口 ``/chat/resume`` 与 ``/chat/interrupt``:
  20. 它们承载的是**不同能力**(中断恢复、挂起查询),不是同一能力的别名。
  21. """
  22. from __future__ import annotations
  23. import re
  24. import uuid
  25. from pathlib import Path
  26. from typing import Any
  27. from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
  28. from fastapi.responses import StreamingResponse
  29. from starlette.background import BackgroundTask
  30. import config
  31. from agents import agent_registry, prompts, execution
  32. from api import concurrency
  33. from api.models import ResumeRequest
  34. from api.sse_core import pending_interrupts, sse_headers
  35. from billing import guard
  36. from db import chat_store, db_conn
  37. from obs import get_logger
  38. logger = get_logger(__name__)
  39. router = APIRouter(tags=["对话"])
  40. # 与 1.5 一致:plan 模式放宽递归上限(要先出计划再执行,步骤天然更多)
  41. RECURSION_LIMIT_PLAN = 500
  42. RECURSION_LIMIT_DEFAULT = 300
  43. VALID_MODES = ("plan", "full")
  44. # 标题自动生成的长度(骨架用截断而非 LLM 调用,见 _ensure_session_title)
  45. TITLE_MAX_CHARS = 30
  46. MAX_UPLOAD_MB = config.get_int("TFAGENTS_MAX_UPLOAD_MB", 20)
  47. MAX_UPLOAD_SIZE = MAX_UPLOAD_MB * 1024 * 1024
  48. _SAFE_NAME_RE = re.compile(r"[^\w\u4e00-\u9fff.\-]+")
  49. # ============================================================
  50. # 请求工具
  51. # ============================================================
  52. def _trace_id_of(request: Request | None) -> str:
  53. """取透传中间件解析出的 trace_id。"""
  54. if request is None:
  55. return ""
  56. return getattr(request.state, "trace_id", "") or ""
  57. def _resolve_user(user_name: str | None) -> str:
  58. """解析归属用户。
  59. **这是 2.0 唯一的身份来源,也是它最大的安全缺口**:没有任何校验,
  60. 客户端填谁就是谁。计费、会话归属、并发配额全都建立在这个值上,所以
  61. 当前实现只适合内网或开发环境。接真实鉴权时把这里替换成依赖注入即可。
  62. """
  63. return (user_name or "default").strip() or "default"
  64. # ============================================================
  65. # 会话与模式
  66. # ============================================================
  67. def _init_session(
  68. session_id: str | None, thread_id: str | None, user_name: str, mode: str
  69. ) -> dict[str, Any]:
  70. """取得或新建会话。返回会话行。
  71. 两条硬约束(沿用 1.5):
  72. 1. ``thread_id`` 必须等于 ``session_id``——checkpointer 是进程级共享的,
  73. 允许客户端指定任意 thread_id 就等于允许读写别人的图状态。
  74. 2. 会话必须归属当前用户,否则按 404 处理(不是 403:不泄露「这个 ID
  75. 存在但不属于你」这个信息)。
  76. """
  77. if thread_id and session_id and thread_id != session_id:
  78. raise HTTPException(
  79. status_code=404, detail={"code": "thread_mismatch", "message": "会话不存在"}
  80. )
  81. if not session_id:
  82. return chat_store.create_session(user_name=user_name, mode=mode)
  83. session = chat_store.get_session(session_id)
  84. if session is None or session.get("user_name") != user_name:
  85. raise HTTPException(
  86. status_code=404, detail={"code": "session_not_found", "message": "会话不存在"}
  87. )
  88. return session
  89. def _setup_mode(session: dict[str, Any], mode: str | None) -> tuple[str, dict[str, Any]]:
  90. """确定本轮权限模式并构造 LangGraph config。
  91. 请求里带了合法 mode 就落库(会话级设置,后续轮次沿用);没带就用会话
  92. 里存的值。模式只影响「注入什么指令」与递归上限——2.0 不用
  93. interrupt_before/after 的静态中断,plan 模式的审批靠工具内 ``interrupt``。
  94. """
  95. current = session.get("mode") or "full"
  96. if mode:
  97. normalized = mode.strip().lower()
  98. if normalized not in VALID_MODES:
  99. raise HTTPException(
  100. status_code=400,
  101. detail={"code": "invalid_mode", "message": f"mode 只能是 {' | '.join(VALID_MODES)}"},
  102. )
  103. if normalized != current:
  104. chat_store.update_session_mode(session["id"], normalized)
  105. current = normalized
  106. recursion_limit = RECURSION_LIMIT_PLAN if current == "plan" else RECURSION_LIMIT_DEFAULT
  107. graph_config = {
  108. "configurable": {"thread_id": session["id"]},
  109. "recursion_limit": recursion_limit,
  110. }
  111. return current, graph_config
  112. def _ensure_session_title(session: dict[str, Any], message: str) -> None:
  113. """首轮时给会话起个标题。
  114. 骨架用**截断而非 LLM 生成**——标题不值得多花一次模型调用的钱和延迟。
  115. 想换成 LLM 摘要只需替换本函数体,调用方不用动。
  116. """
  117. if session.get("title"):
  118. return
  119. title = (message or "").strip().split("\n", 1)[0][:TITLE_MAX_CHARS]
  120. if title:
  121. chat_store.update_session_title(session["id"], title)
  122. session["title"] = title
  123. # ============================================================
  124. # 消息注入
  125. # ============================================================
  126. def _resolve_refs_and_skill(
  127. original: str, user_name: str
  128. ) -> dict[str, Any]:
  129. """解析 ``#skill:`` 与 ``#session:`` 前缀。
  130. 顺序是先技能后引用(与 1.5 一致):两个前缀可以叠加,技能在前。
  131. 技能名不做白名单校验——skill 目录里没有这个名字时,agent 读文件会失败
  132. 并如实上报,不需要在路由层再维护一份名单(那份名单必然会和目录漂移)。
  133. """
  134. text = original or ""
  135. forced_skill: str | None = None
  136. reference: dict[str, Any] | None = None
  137. text, forced_skill = prompts.extract_skill_prefix(text)
  138. text, ref_id = prompts.extract_session_ref(text)
  139. if ref_id:
  140. ref_session = chat_store.get_session(ref_id)
  141. if ref_session is None or ref_session.get("user_name") != user_name:
  142. # 引用不存在的会话不报错,只在指令里说明——报错会让用户
  143. # 因为一个笔误丢掉整条消息
  144. reference = {"title": "", "excerpt": "", "error": "引用的会话不存在"}
  145. else:
  146. recent = chat_store.get_messages(ref_id, limit=6)
  147. excerpt = "\n".join(
  148. f"{'用户' if m.get('msg_role') == 'user' else '助手'}:{(m.get('content') or '')[:300]}"
  149. for m in recent
  150. if m.get("content")
  151. )
  152. reference = {"title": ref_session.get("title") or "未命名会话", "excerpt": excerpt}
  153. return {"message": text, "forced_skill": forced_skill, "reference": reference, "original": original}
  154. def _inject_all(
  155. message: str,
  156. user_name: str,
  157. mode: str,
  158. ref: dict[str, Any],
  159. filename: str | None,
  160. saved_path: str | None,
  161. ) -> str:
  162. """按固定顺序拼装最终消息。
  163. 顺序有意为之:文件说明在最前(它是本轮的事实前提),接着是引用材料,
  164. 最后才是指令(技能、偏好、模式)——指令放最后是为了离模型最近,
  165. 不容易被长材料淹没。
  166. """
  167. parts: list[str] = []
  168. if filename and saved_path:
  169. parts.append(
  170. f"用户上传了文件「{filename}」,文件路径:{saved_path}。"
  171. f"请用 read_file 读取它,不要凭文件名猜测内容。"
  172. )
  173. ref_instruction = prompts.build_ref_instruction(ref.get("reference"))
  174. if ref_instruction:
  175. parts.append(ref_instruction)
  176. if ref.get("forced_skill"):
  177. parts.append(prompts.build_skill_instruction(ref["forced_skill"]))
  178. pref_instruction = prompts.build_preference_instruction(
  179. chat_store.preferences_as_text(user_name)
  180. )
  181. if pref_instruction:
  182. parts.append(pref_instruction)
  183. parts.append(prompts.build_mode_instruction(mode))
  184. return f"{message}{''.join(parts)}"
  185. # ============================================================
  186. # 文件上传
  187. # ============================================================
  188. async def _save_upload(file: UploadFile, user_name: str, session_id: str) -> str:
  189. """把上传文件存进 agent 的沙箱目录,返回虚拟路径。
  190. 存到 ``/workspace/uploads/`` 而不是别处,是因为权限规则里只有
  191. ``/workspace/**`` 是可写的——存到沙箱外 agent 自己都读不到。
  192. """
  193. raw_name = file.filename or "upload.bin"
  194. safe_name = _SAFE_NAME_RE.sub("_", Path(raw_name).name) or "upload.bin"
  195. from agents.workspace import session_root
  196. target_dir = session_root(user_name, session_id) / "workspace" / "uploads"
  197. target_dir.mkdir(parents=True, exist_ok=True)
  198. target = target_dir / f"{uuid.uuid4().hex}_{safe_name}"
  199. size = 0
  200. try:
  201. with target.open("xb") as output:
  202. while chunk := await file.read(64 * 1024):
  203. size += len(chunk)
  204. if size > MAX_UPLOAD_SIZE:
  205. raise HTTPException(status_code=413, detail={"code": "file_too_large", "message": f"文件超过上限 {MAX_UPLOAD_MB}MB"})
  206. output.write(chunk)
  207. if not size:
  208. raise HTTPException(status_code=400, detail={"code": "empty_file", "message": "上传的文件为空"})
  209. except BaseException:
  210. target.unlink(missing_ok=True)
  211. raise
  212. logger.info("已保存上传文件 path=%s size=%d", target, size)
  213. return f"/workspace/uploads/{target.name}"
  214. # ============================================================
  215. # 主入口
  216. # ============================================================
  217. @router.post("/chat")
  218. async def chat(
  219. message: str = Form(...),
  220. session_id: str | None = Form(default=None),
  221. thread_id: str | None = Form(default=None),
  222. mode: str | None = Form(default=None),
  223. file: UploadFile | None = File(default=None),
  224. user_name: str | None = Form(default=None),
  225. request: Request = None,
  226. request_id: str | None = Form(default=None),
  227. ):
  228. """统一对话接口(SSE 流式)。
  229. 请求体:multipart/form-data
  230. - message:用户消息(必填),支持 ``#skill:<name>``、``#session:<uuid>`` 前缀
  231. - session_id:可选,会话 ID(缺省则新建)
  232. - thread_id:可选,必须与 session_id 一致,否则 404
  233. - mode:可选,权限模式(plan | full)
  234. - file:可选,附件
  235. - user_name:可选,归属用户,缺省 default(2.0 新增,替代 1.5 的鉴权依赖)
  236. """
  237. return await _run_chat_pipeline(
  238. message=message,
  239. session_id=session_id,
  240. thread_id=thread_id,
  241. mode=mode,
  242. file=file,
  243. user_name=_resolve_user(user_name),
  244. trace_id=_trace_id_of(request),
  245. request_id=request_id,
  246. )
  247. async def _run_chat_pipeline(*, request_id: str | None = None, **kwargs):
  248. """同一用户的请求键只接受一次;重试返回已有运行 ID。"""
  249. import asyncio
  250. import hashlib
  251. if request_id is None:
  252. return await _execute_chat_pipeline(**kwargs)
  253. if not request_id.strip() or len(request_id) > 128:
  254. raise HTTPException(status_code=400, detail={"code": "invalid_request_id", "message": "request_id 必须为 1 至 128 字符"})
  255. key = hashlib.sha256((kwargs["user_name"] + "\0" + request_id).encode()).hexdigest()
  256. with concurrency.session_guard(f"request:{key}"):
  257. existing = await asyncio.to_thread(db_conn.query_one, "SELECT * FROM request_keys WHERE id = ?", (key,))
  258. if existing:
  259. raise HTTPException(status_code=409, detail={"code": "request_already_processed", "message": "该请求已接受,请查看原会话和执行记录", "session_id": existing["session_id"], "run_id": existing["run_id"]})
  260. response = await _execute_chat_pipeline(**kwargs)
  261. try:
  262. await asyncio.to_thread(db_conn.execute,
  263. "INSERT INTO request_keys (id, session_id, run_id, created_at) VALUES (?, ?, ?, ?)",
  264. (key, response.headers["X-Session-Id"], response.headers["X-Run-Id"], db_conn.fmt_dt()))
  265. except BaseException:
  266. # 响应体尚未迭代时 aclose 不会运行生成器 finally。
  267. await response.background()
  268. raise
  269. return response
  270. async def _execute_chat_pipeline(
  271. message: str,
  272. session_id: str | None,
  273. thread_id: str | None,
  274. mode: str | None,
  275. file: UploadFile | None,
  276. user_name: str,
  277. trace_id: str = "",
  278. ):
  279. """先验证请求,再取得会话独占权,装配共用的执行流程。"""
  280. import asyncio
  281. if mode and mode.strip().lower() not in VALID_MODES:
  282. raise HTTPException(status_code=400, detail={"code": "invalid_mode", "message": "mode 只能是 plan | full"})
  283. if not message.strip():
  284. raise HTTPException(status_code=400, detail={"code": "empty_message", "message": "消息不能为空"})
  285. started_at = db_conn.now()
  286. # 新建会话前也预检,拒绝请求不会产生空会话。
  287. await asyncio.to_thread(guard.precheck, user_name)
  288. session = await asyncio.to_thread(_init_session, session_id, thread_id, user_name, mode or "full")
  289. admission = await execution.admit(user_name, session["id"])
  290. try:
  291. agent = await asyncio.to_thread(agent_registry.get_agent)
  292. graph_config = {"configurable": {"thread_id": session["id"]}}
  293. if await pending_interrupts(agent, graph_config):
  294. raise HTTPException(status_code=409, detail={"code": "pending_interrupt", "message": "请先处理当前待确认请求"})
  295. current_mode, graph_config = await asyncio.to_thread(_setup_mode, session, mode)
  296. await asyncio.to_thread(_ensure_session_title, session, message)
  297. filename = file.filename if file else None
  298. saved_path = await _save_upload(file, user_name, session["id"]) if file else None
  299. ref = await asyncio.to_thread(_resolve_refs_and_skill, message, user_name)
  300. final_message = await asyncio.to_thread(_inject_all, ref["message"], user_name, current_mode, ref, filename, saved_path)
  301. stream = await execution.build_stream(admission, agent=agent,
  302. session_id=session["id"], thread_id=session["id"], config=graph_config,
  303. message=final_message, user_name=user_name, kind="chat", model=config.MODEL,
  304. original_user_message=ref["original"] or message, trace_id=trace_id,
  305. started_at=started_at.timestamp())
  306. return StreamingResponse(stream.generate(), media_type="text/event-stream", background=BackgroundTask(admission.release),
  307. headers={**sse_headers(session["id"]), "X-Run-Id": stream.req.run_id})
  308. except BaseException:
  309. admission.release()
  310. raise
  311. # ============================================================
  312. # 中断恢复
  313. # ============================================================
  314. def _to_official_resume(payload: ResumeRequest, pending_count: int) -> dict[str, Any]:
  315. """把 1.5 的 ResumeRequest 翻译成官方 HITL 的 ``{"decisions": [...]}``。
  316. 官方要求 decisions 与 action_requests **数量严格一一对应**,数量不符会
  317. 抛错,所以这里按 ``pending_count`` 复制同一决策。
  318. 翻译表:
  319. - ``approved=True`` → ``approve``
  320. - ``approved=False`` → ``reject``(带拒绝理由)
  321. - ``status=cancelled`` → ``reject``(用户取消等价于不执行)
  322. - ``status=error`` → ``reject``(前端恢复出错,不执行更安全)
  323. - 有 ``answers`` → ``respond``(把人的回答作为工具结果)
  324. """
  325. count = max(pending_count, 1)
  326. if count > 1 and payload.approved is True and not payload.interrupt_token:
  327. raise HTTPException(status_code=409, detail={"code": "approval_refresh_required", "message": "请刷新并确认完整操作列表"})
  328. if payload.approved is True:
  329. decision: dict[str, Any] = {"type": "approve"}
  330. elif payload.approved is False:
  331. decision = {"type": "reject", "message": "用户拒绝了该操作,请勿重试,改为说明情况"}
  332. elif payload.status == "cancelled":
  333. decision = {"type": "reject", "message": "用户取消了该操作"}
  334. elif payload.status == "error":
  335. decision = {
  336. "type": "reject",
  337. "message": f"前端恢复出错,该操作未执行:{payload.error or '未提供详情'}",
  338. }
  339. elif payload.answers:
  340. decision = {"type": "respond", "message": "\n".join(payload.answers)}
  341. else:
  342. decision = {"type": "reject", "message": "未收到明确答复,该操作未执行"}
  343. return {"decisions": [dict(decision) for _ in range(count)]}
  344. @router.post("/chat/resume")
  345. async def chat_resume(payload: ResumeRequest, request: Request = None):
  346. """在会话独占期间核对当前中断,再恢复图。"""
  347. import asyncio
  348. from api.interrupts import resume_value
  349. user_name = _resolve_user(payload.user_name)
  350. session = await asyncio.to_thread(_init_session, payload.session_id, payload.thread_id, user_name, "full")
  351. admission = await execution.admit(user_name, session["id"])
  352. try:
  353. agent = await asyncio.to_thread(agent_registry.get_agent)
  354. _, graph_config = _setup_mode(session, None)
  355. state = await agent.aget_state(graph_config)
  356. resume_payload = resume_value(payload, state)
  357. stream = await execution.build_stream(admission, agent=agent,
  358. session_id=session["id"], thread_id=session["id"], config=graph_config,
  359. message="", user_name=user_name, kind="resume", model=config.MODEL,
  360. trace_id=_trace_id_of(request), resume_payload=resume_payload)
  361. return StreamingResponse(stream.generate(), media_type="text/event-stream", background=BackgroundTask(admission.release),
  362. headers={**sse_headers(session["id"]), "X-Run-Id": stream.req.run_id})
  363. except BaseException:
  364. admission.release()
  365. raise
  366. @router.get("/chat/interrupt")
  367. async def get_interrupt(session_id: str, user_name: str | None = None):
  368. """查询会话当前挂起的中断。
  369. 前端刷新页面后靠这个接口把提问卡/审批卡补渲染出来——否则用户会看到
  370. 一个「什么都不动」的对话框,因为中断事件是流式的、刷新后就没了。
  371. 有挂起返回 200 + 载荷;无挂起返回 204(无内容)。
  372. """
  373. from fastapi import Response
  374. user = _resolve_user(user_name)
  375. session = chat_store.get_session(session_id)
  376. if session is None or session.get("user_name") != user:
  377. raise HTTPException(
  378. status_code=404, detail={"code": "session_not_found", "message": "会话不存在"}
  379. )
  380. agent = agent_registry.get_agent()
  381. graph_config = {"configurable": {"thread_id": session_id}}
  382. pending = await pending_interrupts(agent, graph_config)
  383. if not pending:
  384. return Response(status_code=204)
  385. from api.interrupts import interrupt_frame
  386. from api.sse_events import EventWriter
  387. import json
  388. writer = EventWriter(session_id)
  389. frames = [json.loads(interrupt_frame(writer, item)[6:]) for item in pending]
  390. return {"session_id": session_id, "interrupts": frames}