chat_store.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. """会话与消息存取。
  2. MySQL 是会话历史的**事实源**:图状态(checkpoint)丢了可以从这里补种,
  3. 反过来不行。所以每次对话结束都必须有一条 assistant 消息落库。
  4. 约定:``messages`` 里的 ``created_at`` 只到秒级且同秒可能重复,因此所有
  5. 「取最近 N 条」的查询都带上 ``rowid``/``id`` 的次级排序,保证分页稳定。
  6. """
  7. from __future__ import annotations
  8. import json
  9. from typing import Any, Sequence
  10. from db import db_conn
  11. from obs import get_logger
  12. logger = get_logger(__name__)
  13. def _dumps(value: Any) -> str | None:
  14. """把结构化字段序列化成 JSON 字符串;None 保持 None。"""
  15. if value is None:
  16. return None
  17. if isinstance(value, str):
  18. return value
  19. return json.dumps(value, ensure_ascii=False)
  20. def _loads(value: Any, default: Any) -> Any:
  21. """反序列化 JSON 字段;空值或坏数据回落默认值(不抛异常)。"""
  22. if value is None or value == "":
  23. return default
  24. if isinstance(value, (list, dict)):
  25. return value
  26. try:
  27. return json.loads(value)
  28. except (TypeError, ValueError):
  29. return default
  30. # ============================================================
  31. # 会话
  32. # ============================================================
  33. def create_session(user_name: str, mode: str = "full", title: str = "") -> dict[str, Any]:
  34. """新建会话并返回其完整行。会话 ID 即 LangGraph thread_id。"""
  35. session_id = db_conn.new_id()
  36. stamp = db_conn.fmt_dt()
  37. with db_conn.transaction():
  38. db_conn.execute(
  39. "INSERT INTO sessions (id, title, user_name, mode, pinned, created_at, updated_at)"
  40. " VALUES (?, ?, ?, ?, 0, ?, ?)",
  41. (session_id, title, user_name, mode, stamp, stamp),
  42. )
  43. logger.info("新建会话 session_id=%s user=%s mode=%s", session_id, user_name, mode)
  44. return get_session(session_id) or {}
  45. def get_session(session_id: str) -> dict[str, Any] | None:
  46. """按 ID 取会话;不存在返回 None。"""
  47. row = db_conn.query_one("SELECT * FROM sessions WHERE id = ?", (session_id,))
  48. return _hydrate_session(row)
  49. def get_session_for_user(session_id: str, user_name: str) -> dict[str, Any] | None:
  50. """取会话并校验归属。用于 400/404 判定前的存在性与越权检查。"""
  51. session = get_session(session_id)
  52. if session and session.get("user_name") != user_name:
  53. return None
  54. return session
  55. def list_sessions(user_name: str, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
  56. """列出某用户的会话,置顶优先、更新时间倒序。"""
  57. rows = db_conn.query(
  58. "SELECT * FROM sessions WHERE user_name = ?"
  59. " ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ? OFFSET ?",
  60. (user_name, limit, offset),
  61. )
  62. return [_hydrate_session(row) for row in rows]
  63. def search_sessions(user_name: str, keyword: str, limit: int = 50) -> list[dict[str, Any]]:
  64. """按标题模糊搜索会话。"""
  65. rows = db_conn.query(
  66. "SELECT * FROM sessions WHERE user_name = ? AND title LIKE ?"
  67. " ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?",
  68. (user_name, f"%{keyword}%", limit),
  69. )
  70. return [_hydrate_session(row) for row in rows]
  71. def update_session_title(session_id: str, title: str) -> None:
  72. """更新会话标题。"""
  73. db_conn.execute(
  74. "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
  75. (title, db_conn.fmt_dt(), session_id),
  76. )
  77. def update_session_mode(session_id: str, mode: str) -> None:
  78. """更新会话的权限模式(plan | full)。"""
  79. db_conn.execute(
  80. "UPDATE sessions SET mode = ?, updated_at = ? WHERE id = ?",
  81. (mode, db_conn.fmt_dt(), session_id),
  82. )
  83. def update_session_pin(session_id: str, pinned: bool) -> None:
  84. """设置/取消会话置顶。"""
  85. db_conn.execute(
  86. "UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?",
  87. (1 if pinned else 0, db_conn.fmt_dt(), session_id),
  88. )
  89. def touch_session(session_id: str) -> None:
  90. """刷新会话的 updated_at,让最近活跃的会话排在列表前面。"""
  91. db_conn.execute(
  92. "UPDATE sessions SET updated_at = ? WHERE id = ?", (db_conn.fmt_dt(), session_id)
  93. )
  94. def delete_session(session_id: str) -> None:
  95. """删除会话及其全部消息、用量、压缩存档。
  96. 注意:调用方还须清除 LangGraph checkpoint 线程,否则图状态会残留并
  97. 在下次复用同 ID 时「诈尸」。
  98. """
  99. with db_conn.transaction():
  100. db_conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
  101. db_conn.execute("DELETE FROM session_context_usage WHERE session_id = ?", (session_id,))
  102. db_conn.execute("DELETE FROM context_archives WHERE session_id = ?", (session_id,))
  103. db_conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
  104. logger.info("删除会话 session_id=%s", session_id)
  105. # ============================================================
  106. # 消息
  107. # ============================================================
  108. def save_message(
  109. session_id: str,
  110. role: str,
  111. content: str,
  112. tool_calls: Any = None,
  113. citations: Any = None,
  114. ) -> str:
  115. """落一条消息,返回消息 ID。user 与 assistant 都走这里。"""
  116. message_id = db_conn.new_id()
  117. stamp = db_conn.fmt_dt()
  118. with db_conn.transaction():
  119. db_conn.execute(
  120. "INSERT INTO messages (id, session_id, msg_role, content, tool_calls, citations, created_at)"
  121. " VALUES (?, ?, ?, ?, ?, ?, ?)",
  122. (
  123. message_id,
  124. session_id,
  125. role,
  126. content or "",
  127. _dumps(tool_calls),
  128. _dumps(citations),
  129. stamp,
  130. ),
  131. )
  132. db_conn.execute(
  133. "UPDATE sessions SET updated_at = ? WHERE id = ?", (stamp, session_id)
  134. )
  135. return message_id
  136. def get_messages(
  137. session_id: str, limit: int | None = None, offset: int = 0
  138. ) -> list[dict[str, Any]]:
  139. """取会话消息,按时间正序返回。
  140. ``limit`` 给定时取**最近** limit 条(先倒序截断再正序还原),这正是
  141. 冷启动补种图状态需要的语义。
  142. """
  143. if limit is None:
  144. rows = db_conn.query(
  145. "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC"
  146. " LIMIT ? OFFSET ?",
  147. (session_id, 100000, offset),
  148. )
  149. elif offset == 0:
  150. rows = db_conn.query(
  151. "SELECT * FROM (SELECT * FROM messages WHERE session_id = ?"
  152. " ORDER BY created_at DESC, id DESC LIMIT ?) AS recent"
  153. " ORDER BY created_at ASC, id ASC",
  154. (session_id, limit),
  155. )
  156. else:
  157. # 带偏移的分页:先按正序定位窗口
  158. rows = db_conn.query(
  159. "SELECT * FROM messages WHERE session_id = ?"
  160. " ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?",
  161. (session_id, limit, offset),
  162. )
  163. return [_hydrate_message(row) for row in rows]
  164. def count_messages(session_id: str) -> int:
  165. """会话消息条数。"""
  166. return int(db_conn.scalar("SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,), 0) or 0)
  167. def delete_message(message_id: str) -> bool:
  168. """删除单条消息,返回是否命中。"""
  169. return db_conn.execute("DELETE FROM messages WHERE id = ?", (message_id,)) > 0
  170. # ============================================================
  171. # 上下文用量与压缩存档
  172. # ============================================================
  173. def save_context_usage(
  174. session_id: str,
  175. turn: int,
  176. input_tokens: int,
  177. output_tokens: int,
  178. cache_read_tokens: int = 0,
  179. usage_source: str = "provider",
  180. compacted: bool = False,
  181. compact_count: int = 0,
  182. snapshot: Any = None,
  183. ) -> str:
  184. """记录一轮对话的上下文用量。"""
  185. row_id = db_conn.new_id()
  186. total = int(input_tokens) + int(output_tokens)
  187. db_conn.execute(
  188. "INSERT INTO session_context_usage (id, session_id, turn, input_tokens, output_tokens,"
  189. " cache_read_tokens, total_tokens, usage_source, compacted, compact_count, snapshot, created_at)"
  190. " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  191. (
  192. row_id,
  193. session_id,
  194. int(turn),
  195. int(input_tokens),
  196. int(output_tokens),
  197. int(cache_read_tokens),
  198. total,
  199. usage_source,
  200. 1 if compacted else 0,
  201. int(compact_count),
  202. _dumps(snapshot),
  203. db_conn.fmt_dt(),
  204. ),
  205. )
  206. return row_id
  207. def get_context_usage(session_id: str, limit: int = 50) -> dict[str, Any]:
  208. """取会话的用量明细(倒序)与会话累计压缩次数。"""
  209. rows = db_conn.query(
  210. "SELECT * FROM session_context_usage WHERE session_id = ?"
  211. " ORDER BY created_at DESC, id DESC LIMIT ?",
  212. (session_id, limit),
  213. )
  214. history = []
  215. for row in rows:
  216. item = dict(row)
  217. item["snapshot"] = _loads(item.get("snapshot"), {})
  218. item["compacted"] = bool(item.get("compacted"))
  219. history.append(item)
  220. compact_count = int(
  221. db_conn.scalar(
  222. "SELECT MAX(compact_count) FROM session_context_usage WHERE session_id = ?",
  223. (session_id,),
  224. 0,
  225. )
  226. or 0
  227. )
  228. latest = history[0] if history else None
  229. return {
  230. "session_id": session_id,
  231. "compact_count": compact_count,
  232. "latest": latest,
  233. "history": history,
  234. }
  235. def save_context_archive(
  236. session_id: str, turn: int, file_path: str, tokens: int = 0
  237. ) -> str:
  238. """登记一次「压缩前原文」的落盘位置,供事后追溯。"""
  239. row_id = db_conn.new_id()
  240. db_conn.execute(
  241. "INSERT INTO context_archives (id, session_id, turn, file_path, tokens, created_at)"
  242. " VALUES (?, ?, ?, ?, ?, ?)",
  243. (row_id, session_id, int(turn), file_path, int(tokens), db_conn.fmt_dt()),
  244. )
  245. return row_id
  246. def list_context_archives(session_id: str) -> list[dict[str, Any]]:
  247. """列出会话的压缩存档索引。"""
  248. return db_conn.query(
  249. "SELECT * FROM context_archives WHERE session_id = ? ORDER BY created_at ASC",
  250. (session_id,),
  251. )
  252. # ============================================================
  253. # 用户偏好
  254. # ============================================================
  255. def save_preference(user_name: str, key: str, value: str) -> None:
  256. """写入/覆盖一条用户偏好(先查后写,避免方言相关的 upsert 语法)。"""
  257. existing = db_conn.query_one(
  258. "SELECT id FROM user_preferences WHERE user_name = ? AND pref_key = ?",
  259. (user_name, key),
  260. )
  261. stamp = db_conn.fmt_dt()
  262. if existing:
  263. db_conn.execute(
  264. "UPDATE user_preferences SET pref_value = ?, updated_at = ? WHERE id = ?",
  265. (value, stamp, existing["id"]),
  266. )
  267. else:
  268. db_conn.execute(
  269. "INSERT INTO user_preferences (id, user_name, pref_key, pref_value, updated_at)"
  270. " VALUES (?, ?, ?, ?, ?)",
  271. (db_conn.new_id(), user_name, key, value, stamp),
  272. )
  273. def list_preferences(user_name: str) -> list[dict[str, Any]]:
  274. """列出某用户的全部偏好。"""
  275. return db_conn.query(
  276. "SELECT pref_key, pref_value, updated_at FROM user_preferences"
  277. " WHERE user_name = ? ORDER BY pref_key ASC",
  278. (user_name,),
  279. )
  280. def delete_preference(user_name: str, key: str) -> bool:
  281. """删除一条偏好,返回是否命中。"""
  282. return db_conn.execute(
  283. "DELETE FROM user_preferences WHERE user_name = ? AND pref_key = ?",
  284. (user_name, key),
  285. ) > 0
  286. def preferences_as_text(user_name: str, limit: int = 20) -> str:
  287. """把偏好渲染成可注入提示词的一行文本;无偏好返回空串。"""
  288. rows = list_preferences(user_name)[:limit]
  289. if not rows:
  290. return ""
  291. pairs = [f"{row['pref_key']}={row['pref_value']}" for row in rows]
  292. return ";".join(pairs)
  293. # ============================================================
  294. # 行归一化
  295. # ============================================================
  296. def _hydrate_session(row: dict[str, Any] | None) -> dict[str, Any] | None:
  297. if not row:
  298. return None
  299. session = dict(row)
  300. session["pinned"] = bool(session.get("pinned"))
  301. return session
  302. def _hydrate_message(row: dict[str, Any]) -> dict[str, Any]:
  303. message = dict(row)
  304. message["tool_calls"] = _loads(message.get("tool_calls"), [])
  305. message["citations"] = _loads(message.get("citations"), [])
  306. return message