chat_routes.py 20 KB

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