session_routes.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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. await asyncio.to_thread(chat_store.delete_session, session_id)
  58. await agent_memory.delete_thread(session_id)
  59. return {"deleted": True, "session_id": session_id}
  60. @router.delete("/messages/{message_id}")
  61. async def delete_message(message_id: str, user_name: str | None = None):
  62. """删除单条消息。"""
  63. deleted = await asyncio.to_thread(chat_store.delete_message, message_id)
  64. if not deleted:
  65. raise HTTPException(
  66. status_code=404, detail={"code": "message_not_found", "message": "消息不存在"}
  67. )
  68. return {"deleted": True, "message_id": message_id}
  69. @router.get("/sessions/{session_id}/mode")
  70. async def get_mode(session_id: str, user_name: str | None = None):
  71. """读会话的权限模式。"""
  72. user = (user_name or "default").strip() or "default"
  73. session = await asyncio.to_thread(_require_session, session_id, user)
  74. return {"session_id": session_id, "mode": session.get("mode") or "full"}
  75. @router.put("/sessions/{session_id}/mode")
  76. async def put_mode(session_id: str, payload: ModeUpdateRequest, user_name: str | None = None):
  77. """设置会话的权限模式。"""
  78. user = (user_name or "default").strip() or "default"
  79. await asyncio.to_thread(_require_session, session_id, user)
  80. mode = payload.mode.strip().lower()
  81. if mode not in VALID_MODES:
  82. raise HTTPException(
  83. status_code=400,
  84. detail={"code": "invalid_mode", "message": f"mode 只能是 {' | '.join(VALID_MODES)}"},
  85. )
  86. await asyncio.to_thread(chat_store.update_session_mode, session_id, mode)
  87. return {"session_id": session_id, "mode": mode}
  88. @router.put("/sessions/{session_id}/pin")
  89. async def put_pin(session_id: str, payload: PinUpdateRequest, user_name: str | None = None):
  90. """设置/取消置顶。"""
  91. user = (user_name or "default").strip() or "default"
  92. await asyncio.to_thread(_require_session, session_id, user)
  93. await asyncio.to_thread(chat_store.update_session_pin, session_id, payload.pinned)
  94. return {"session_id": session_id, "pinned": payload.pinned}
  95. @router.put("/sessions/{session_id}/title")
  96. async def put_title(session_id: str, payload: TitleUpdateRequest, 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. await asyncio.to_thread(chat_store.update_session_title, session_id, payload.title)
  101. return {"session_id": session_id, "title": payload.title}
  102. @router.get("/sessions/{session_id}/context")
  103. async def get_context(session_id: str, user_name: str | None = None, limit: int = 50):
  104. """上下文用量明细。
  105. 口径以模型返回的真实 usage 为准,``usage_source=estimate`` 的那几条是
  106. provider 没返回用量时的本地估算,可信度较低(前端应区别展示)。
  107. """
  108. user = (user_name or "default").strip() or "default"
  109. await asyncio.to_thread(_require_session, session_id, user)
  110. usage = await asyncio.to_thread(chat_store.get_context_usage, session_id, min(limit, 200))
  111. archives = await asyncio.to_thread(chat_store.list_context_archives, session_id)
  112. from agents import model_factory
  113. return {
  114. **usage,
  115. "context_window": model_factory.context_window(),
  116. "archives": archives,
  117. }
  118. # ============================================================
  119. # 用户偏好
  120. # ============================================================
  121. @router.get("/preferences")
  122. async def list_preferences(user_name: str | None = None):
  123. """列出用户偏好。"""
  124. user = (user_name or "default").strip() or "default"
  125. items = await asyncio.to_thread(chat_store.list_preferences, user)
  126. return {"user_name": user, "items": items}
  127. @router.put("/preferences")
  128. async def put_preference(
  129. key: str, value: str, user_name: str | None = None
  130. ):
  131. """写入/覆盖一条偏好。每轮对话会自动注入,所以只放长期有效的设定。"""
  132. user = (user_name or "default").strip() or "default"
  133. if not key.strip():
  134. raise HTTPException(status_code=400, detail={"code": "invalid_key", "message": "key 不能为空"})
  135. await asyncio.to_thread(chat_store.save_preference, user, key.strip(), value)
  136. return {"user_name": user, "key": key.strip(), "value": value}
  137. @router.delete("/preferences/{key}")
  138. async def delete_preference(key: str, user_name: str | None = None):
  139. """删除一条偏好。"""
  140. user = (user_name or "default").strip() or "default"
  141. deleted = await asyncio.to_thread(chat_store.delete_preference, user, key)
  142. if not deleted:
  143. raise HTTPException(
  144. status_code=404, detail={"code": "preference_not_found", "message": "偏好不存在"}
  145. )
  146. return {"deleted": True, "key": key}