session_routes.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """会话与历史路由。
  2. 所有 DB 访问都走 ``asyncio.to_thread``:pymysql/sqlite3 都是同步驱动,
  3. 直接在协程里跑会阻塞事件循环——单进程下这意味着「一个用户查历史,所有人
  4. 的流都卡住」。这是 1.5 踩过的坑,2.0 从一开始就分线程。
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. from typing import Any
  9. from fastapi import APIRouter, HTTPException
  10. from agents import agent_memory
  11. from api.models import ModeUpdateRequest, PinUpdateRequest, TitleUpdateRequest
  12. from db import chat_store
  13. from obs import get_logger
  14. logger = get_logger(__name__)
  15. router = APIRouter(tags=["会话"])
  16. VALID_MODES = ("plan", "full")
  17. def _require_session(session_id: str, user_name: str) -> dict[str, Any]:
  18. """取会话并校验归属;不存在或不属于该用户一律 404。"""
  19. session = chat_store.get_session(session_id)
  20. if session is None or session.get("user_name") != user_name:
  21. raise HTTPException(
  22. status_code=404, detail={"code": "session_not_found", "message": "会话不存在"}
  23. )
  24. return session
  25. @router.get("/sessions")
  26. async def list_sessions(user_name: str | None = None, limit: int = 50, offset: int = 0):
  27. """列出会话(置顶优先、更新时间倒序)。"""
  28. user = (user_name or "default").strip() or "default"
  29. items = await asyncio.to_thread(chat_store.list_sessions, user, min(limit, 200), max(offset, 0))
  30. return {"user_name": user, "items": items, "count": len(items)}
  31. @router.get("/sessions/search")
  32. async def search_sessions(q: str, user_name: str | None = None, limit: int = 50):
  33. """按标题模糊搜索会话。"""
  34. user = (user_name or "default").strip() or "default"
  35. items = await asyncio.to_thread(chat_store.search_sessions, user, q, min(limit, 200))
  36. return {"user_name": user, "keyword": q, "items": items, "count": len(items)}
  37. @router.get("/chat/history/{session_id}")
  38. async def chat_history(
  39. session_id: str, user_name: str | None = None, limit: int = 100, offset: int = 0
  40. ):
  41. """取会话历史消息(时间正序)。"""
  42. user = (user_name or "default").strip() or "default"
  43. await asyncio.to_thread(_require_session, session_id, user)
  44. items = await asyncio.to_thread(
  45. chat_store.get_messages, session_id, min(limit, 500), max(offset, 0)
  46. )
  47. from db import business_store
  48. name = await asyncio.to_thread(business_store.session_agent, session_id)
  49. return {"session_id": session_id, "agent_name": name or "main", "items": items, "count": len(items)}
  50. @router.delete("/sessions/{session_id}")
  51. async def delete_session(session_id: str, user_name: str | None = None):
  52. """删除会话。
  53. 必须**同时**清 checkpoint 线程:会话元数据删了但图状态还在的话,
  54. 下次若有同 ID 的会话(或用户重建)会读到上一个会话的上下文——
  55. 表现为「新会话里 agent 记得不该记得的事」。
  56. """
  57. user = (user_name or "default").strip() or "default"
  58. await asyncio.to_thread(_require_session, session_id, user)
  59. from runtime.concurrency import session_guard
  60. with session_guard(session_id):
  61. await agent_memory.delete_thread(session_id)
  62. await asyncio.to_thread(chat_store.delete_session, session_id)
  63. return {"deleted": True, "session_id": session_id}
  64. @router.delete("/messages/{message_id}")
  65. async def delete_message(message_id: str, user_name: str | None = None):
  66. """归属校验后删除历史,并清除图状态以便下次按新历史重建。"""
  67. from db import db_conn
  68. from runtime.concurrency import session_guard
  69. user = (user_name or "default").strip() or "default"
  70. message = await asyncio.to_thread(db_conn.query_one,
  71. "SELECT m.* FROM messages m JOIN sessions s ON s.id = m.session_id WHERE m.id = ? AND s.user_name = ?",
  72. (message_id, user))
  73. if not message:
  74. raise HTTPException(status_code=404, detail={"code": "message_not_found", "message": "消息不存在"})
  75. with session_guard(message["session_id"]):
  76. from agents import agent_registry
  77. from runtime.interrupts import pending_interrupts
  78. agent = await asyncio.to_thread(agent_registry.get_agent)
  79. if await pending_interrupts(agent, {"configurable": {"thread_id": message["session_id"]}}):
  80. raise HTTPException(status_code=409, detail={"code": "pending_interrupt", "message": "请先完成或拒绝待确认操作,再删除消息"})
  81. await agent_memory.delete_thread(message["session_id"])
  82. deleted = await asyncio.to_thread(chat_store.delete_message, message_id)
  83. return {"deleted": deleted, "message_id": message_id}
  84. @router.get("/sessions/{session_id}/runs")
  85. async def execution_runs(session_id: str, user_name: str | None = None):
  86. from db import run_store
  87. await asyncio.to_thread(_require_session, session_id, (user_name or "default").strip() or "default")
  88. return {"items": await asyncio.to_thread(run_store.list_runs, session_id)}
  89. @router.get("/sessions/{session_id}/mode")
  90. async def get_mode(session_id: str, user_name: str | None = None):
  91. """读会话的权限模式。"""
  92. user = (user_name or "default").strip() or "default"
  93. session = await asyncio.to_thread(_require_session, session_id, user)
  94. return {"session_id": session_id, "mode": session.get("mode") or "full"}
  95. @router.put("/sessions/{session_id}/mode")
  96. async def put_mode(session_id: str, payload: ModeUpdateRequest, user_name: str | None = None):
  97. """设置会话的权限模式。"""
  98. user = (user_name or "default").strip() or "default"
  99. await asyncio.to_thread(_require_session, session_id, user)
  100. mode = payload.mode.strip().lower()
  101. if mode not in VALID_MODES:
  102. raise HTTPException(
  103. status_code=400,
  104. detail={"code": "invalid_mode", "message": f"mode 只能是 {' | '.join(VALID_MODES)}"},
  105. )
  106. await asyncio.to_thread(chat_store.update_session_mode, session_id, mode)
  107. return {"session_id": session_id, "mode": mode}
  108. @router.put("/sessions/{session_id}/pin")
  109. async def put_pin(session_id: str, payload: PinUpdateRequest, user_name: str | None = None):
  110. """设置/取消置顶。"""
  111. user = (user_name or "default").strip() or "default"
  112. await asyncio.to_thread(_require_session, session_id, user)
  113. await asyncio.to_thread(chat_store.update_session_pin, session_id, payload.pinned)
  114. return {"session_id": session_id, "pinned": payload.pinned}
  115. @router.put("/sessions/{session_id}/title")
  116. async def put_title(session_id: str, payload: TitleUpdateRequest, user_name: str | None = None):
  117. """重命名会话。"""
  118. user = (user_name or "default").strip() or "default"
  119. await asyncio.to_thread(_require_session, session_id, user)
  120. await asyncio.to_thread(chat_store.update_session_title, session_id, payload.title)
  121. return {"session_id": session_id, "title": payload.title}
  122. @router.get("/sessions/{session_id}/context")
  123. async def get_context(session_id: str, user_name: str | None = None, limit: int = 50):
  124. """上下文用量明细。
  125. 口径以模型返回的真实 usage 为准,``usage_source=estimate`` 的那几条是
  126. provider 没返回用量时的本地估算,可信度较低(前端应区别展示)。
  127. """
  128. user = (user_name or "default").strip() or "default"
  129. await asyncio.to_thread(_require_session, session_id, user)
  130. usage = await asyncio.to_thread(chat_store.get_context_usage, session_id, min(limit, 200))
  131. archives = await asyncio.to_thread(chat_store.list_context_archives, session_id)
  132. from agents import model_factory
  133. return {
  134. **usage,
  135. "context_window": model_factory.context_window(),
  136. "archives": archives,
  137. }
  138. # ============================================================
  139. # 用户偏好
  140. # ============================================================
  141. @router.get("/preferences")
  142. async def list_preferences(user_name: str | None = None):
  143. """列出用户偏好。"""
  144. user = (user_name or "default").strip() or "default"
  145. items = await asyncio.to_thread(chat_store.list_preferences, user)
  146. return {"user_name": user, "items": items}
  147. @router.put("/preferences")
  148. async def put_preference(
  149. key: str, value: str, user_name: str | None = None
  150. ):
  151. """写入/覆盖一条偏好。每轮对话会自动注入,所以只放长期有效的设定。"""
  152. user = (user_name or "default").strip() or "default"
  153. if not key.strip():
  154. raise HTTPException(status_code=400, detail={"code": "invalid_key", "message": "key 不能为空"})
  155. await asyncio.to_thread(chat_store.save_preference, user, key.strip(), value)
  156. return {"user_name": user, "key": key.strip(), "value": value}
  157. @router.delete("/preferences/{key}")
  158. async def delete_preference(key: str, user_name: str | None = None):
  159. """删除一条偏好。"""
  160. user = (user_name or "default").strip() or "default"
  161. deleted = await asyncio.to_thread(chat_store.delete_preference, user, key)
  162. if not deleted:
  163. raise HTTPException(
  164. status_code=404, detail={"code": "preference_not_found", "message": "偏好不存在"}
  165. )
  166. return {"deleted": True, "key": key}