session_routes.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. return {"session_id": session_id, "items": items, "count": len(items)}
  48. @router.delete("/sessions/{session_id}")
  49. async def delete_session(session_id: str, user_name: str | None = None):
  50. """删除会话。
  51. 必须**同时**清 checkpoint 线程:会话元数据删了但图状态还在的话,
  52. 下次若有同 ID 的会话(或用户重建)会读到上一个会话的上下文——
  53. 表现为「新会话里 agent 记得不该记得的事」。
  54. """
  55. user = (user_name or "default").strip() or "default"
  56. await asyncio.to_thread(_require_session, session_id, user)
  57. from runtime.concurrency import session_guard
  58. with session_guard(session_id):
  59. await agent_memory.delete_thread(session_id)
  60. await asyncio.to_thread(chat_store.delete_session, session_id)
  61. return {"deleted": True, "session_id": session_id}
  62. @router.delete("/messages/{message_id}")
  63. async def delete_message(message_id: str, user_name: str | None = None):
  64. """归属校验后删除历史,并清除图状态以便下次按新历史重建。"""
  65. from db import db_conn
  66. from runtime.concurrency import session_guard
  67. user = (user_name or "default").strip() or "default"
  68. message = await asyncio.to_thread(db_conn.query_one,
  69. "SELECT m.* FROM messages m JOIN sessions s ON s.id = m.session_id WHERE m.id = ? AND s.user_name = ?",
  70. (message_id, user))
  71. if not message:
  72. raise HTTPException(status_code=404, detail={"code": "message_not_found", "message": "消息不存在"})
  73. with session_guard(message["session_id"]):
  74. from agents import agent_registry
  75. from runtime.interrupts import pending_interrupts
  76. agent = await asyncio.to_thread(agent_registry.get_agent)
  77. if await pending_interrupts(agent, {"configurable": {"thread_id": message["session_id"]}}):
  78. raise HTTPException(status_code=409, detail={"code": "pending_interrupt", "message": "请先完成或拒绝待确认操作,再删除消息"})
  79. await agent_memory.delete_thread(message["session_id"])
  80. deleted = await asyncio.to_thread(chat_store.delete_message, message_id)
  81. return {"deleted": deleted, "message_id": message_id}
  82. @router.get("/sessions/{session_id}/runs")
  83. async def execution_runs(session_id: str, user_name: str | None = None):
  84. from db import run_store
  85. await asyncio.to_thread(_require_session, session_id, (user_name or "default").strip() or "default")
  86. return {"items": await asyncio.to_thread(run_store.list_runs, session_id)}
  87. @router.get("/sessions/{session_id}/mode")
  88. async def get_mode(session_id: str, user_name: str | None = None):
  89. """读会话的权限模式。"""
  90. user = (user_name or "default").strip() or "default"
  91. session = await asyncio.to_thread(_require_session, session_id, user)
  92. return {"session_id": session_id, "mode": session.get("mode") or "full"}
  93. @router.put("/sessions/{session_id}/mode")
  94. async def put_mode(session_id: str, payload: ModeUpdateRequest, user_name: str | None = None):
  95. """设置会话的权限模式。"""
  96. user = (user_name or "default").strip() or "default"
  97. await asyncio.to_thread(_require_session, session_id, user)
  98. mode = payload.mode.strip().lower()
  99. if mode not in VALID_MODES:
  100. raise HTTPException(
  101. status_code=400,
  102. detail={"code": "invalid_mode", "message": f"mode 只能是 {' | '.join(VALID_MODES)}"},
  103. )
  104. await asyncio.to_thread(chat_store.update_session_mode, session_id, mode)
  105. return {"session_id": session_id, "mode": mode}
  106. @router.put("/sessions/{session_id}/pin")
  107. async def put_pin(session_id: str, payload: PinUpdateRequest, user_name: str | None = None):
  108. """设置/取消置顶。"""
  109. user = (user_name or "default").strip() or "default"
  110. await asyncio.to_thread(_require_session, session_id, user)
  111. await asyncio.to_thread(chat_store.update_session_pin, session_id, payload.pinned)
  112. return {"session_id": session_id, "pinned": payload.pinned}
  113. @router.put("/sessions/{session_id}/title")
  114. async def put_title(session_id: str, payload: TitleUpdateRequest, user_name: str | None = None):
  115. """重命名会话。"""
  116. user = (user_name or "default").strip() or "default"
  117. await asyncio.to_thread(_require_session, session_id, user)
  118. await asyncio.to_thread(chat_store.update_session_title, session_id, payload.title)
  119. return {"session_id": session_id, "title": payload.title}
  120. @router.get("/sessions/{session_id}/context")
  121. async def get_context(session_id: str, user_name: str | None = None, limit: int = 50):
  122. """上下文用量明细。
  123. 口径以模型返回的真实 usage 为准,``usage_source=estimate`` 的那几条是
  124. provider 没返回用量时的本地估算,可信度较低(前端应区别展示)。
  125. """
  126. user = (user_name or "default").strip() or "default"
  127. await asyncio.to_thread(_require_session, session_id, user)
  128. usage = await asyncio.to_thread(chat_store.get_context_usage, session_id, min(limit, 200))
  129. archives = await asyncio.to_thread(chat_store.list_context_archives, session_id)
  130. from agents import model_factory
  131. return {
  132. **usage,
  133. "context_window": model_factory.context_window(),
  134. "archives": archives,
  135. }
  136. # ============================================================
  137. # 用户偏好
  138. # ============================================================
  139. @router.get("/preferences")
  140. async def list_preferences(user_name: str | None = None):
  141. """列出用户偏好。"""
  142. user = (user_name or "default").strip() or "default"
  143. items = await asyncio.to_thread(chat_store.list_preferences, user)
  144. return {"user_name": user, "items": items}
  145. @router.put("/preferences")
  146. async def put_preference(
  147. key: str, value: str, user_name: str | None = None
  148. ):
  149. """写入/覆盖一条偏好。每轮对话会自动注入,所以只放长期有效的设定。"""
  150. user = (user_name or "default").strip() or "default"
  151. if not key.strip():
  152. raise HTTPException(status_code=400, detail={"code": "invalid_key", "message": "key 不能为空"})
  153. await asyncio.to_thread(chat_store.save_preference, user, key.strip(), value)
  154. return {"user_name": user, "key": key.strip(), "value": value}
  155. @router.delete("/preferences/{key}")
  156. async def delete_preference(key: str, user_name: str | None = None):
  157. """删除一条偏好。"""
  158. user = (user_name or "default").strip() or "default"
  159. deleted = await asyncio.to_thread(chat_store.delete_preference, user, key)
  160. if not deleted:
  161. raise HTTPException(
  162. status_code=404, detail={"code": "preference_not_found", "message": "偏好不存在"}
  163. )
  164. return {"deleted": True, "key": key}