chat_store.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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 execution_runs WHERE session_id = ?", (session_id,))
  104. db_conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
  105. logger.info("删除会话 session_id=%s", session_id)
  106. # ============================================================
  107. # 消息
  108. # ============================================================
  109. def save_message(
  110. session_id: str,
  111. role: str,
  112. content: str,
  113. tool_calls: Any = None,
  114. citations: Any = None,
  115. ) -> str:
  116. """落一条消息,返回消息 ID。user 与 assistant 都走这里。"""
  117. message_id = db_conn.new_id()
  118. stamp = db_conn.fmt_dt()
  119. with db_conn.transaction():
  120. db_conn.execute(
  121. "INSERT INTO messages (id, session_id, msg_role, content, tool_calls, citations, created_at)"
  122. " VALUES (?, ?, ?, ?, ?, ?, ?)",
  123. (
  124. message_id,
  125. session_id,
  126. role,
  127. content or "",
  128. _dumps(tool_calls),
  129. _dumps(citations),
  130. stamp,
  131. ),
  132. )
  133. db_conn.execute(
  134. "UPDATE sessions SET updated_at = ? WHERE id = ?", (stamp, session_id)
  135. )
  136. return message_id
  137. def get_messages(
  138. session_id: str, limit: int | None = None, offset: int = 0
  139. ) -> list[dict[str, Any]]:
  140. """取会话消息,按时间正序返回。
  141. ``limit`` 给定时取**最近** limit 条(先倒序截断再正序还原),这正是
  142. 冷启动补种图状态需要的语义。
  143. """
  144. if limit is None:
  145. rows = db_conn.query(
  146. "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC"
  147. " LIMIT ? OFFSET ?",
  148. (session_id, 100000, offset),
  149. )
  150. elif offset == 0:
  151. rows = db_conn.query(
  152. "SELECT * FROM (SELECT * FROM messages WHERE session_id = ?"
  153. " ORDER BY created_at DESC, id DESC LIMIT ?) AS recent"
  154. " ORDER BY created_at ASC, id ASC",
  155. (session_id, limit),
  156. )
  157. else:
  158. # 带偏移的分页:先按正序定位窗口
  159. rows = db_conn.query(
  160. "SELECT * FROM messages WHERE session_id = ?"
  161. " ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?",
  162. (session_id, limit, offset),
  163. )
  164. return [_hydrate_message(row) for row in rows]
  165. def count_messages(session_id: str) -> int:
  166. """会话消息条数。"""
  167. return int(db_conn.scalar("SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,), 0) or 0)
  168. def delete_message(message_id: str) -> bool:
  169. """删除单条消息,返回是否命中。"""
  170. return db_conn.execute("DELETE FROM messages WHERE id = ?", (message_id,)) > 0
  171. # ============================================================
  172. # 上下文用量与压缩存档
  173. # ============================================================
  174. def save_context_usage(
  175. session_id: str,
  176. turn: int,
  177. input_tokens: int,
  178. output_tokens: int,
  179. cache_read_tokens: int = 0,
  180. usage_source: str = "provider",
  181. compacted: bool = False,
  182. compact_count: int = 0,
  183. snapshot: Any = None,
  184. ) -> str:
  185. """记录一轮对话的上下文用量。"""
  186. row_id = db_conn.new_id()
  187. total = int(input_tokens) + int(output_tokens)
  188. db_conn.execute(
  189. "INSERT INTO session_context_usage (id, session_id, turn, input_tokens, output_tokens,"
  190. " cache_read_tokens, total_tokens, usage_source, compacted, compact_count, snapshot, created_at)"
  191. " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  192. (
  193. row_id,
  194. session_id,
  195. int(turn),
  196. int(input_tokens),
  197. int(output_tokens),
  198. int(cache_read_tokens),
  199. total,
  200. usage_source,
  201. 1 if compacted else 0,
  202. int(compact_count),
  203. _dumps(snapshot),
  204. db_conn.fmt_dt(),
  205. ),
  206. )
  207. return row_id
  208. def get_context_usage(session_id: str, limit: int = 50) -> dict[str, Any]:
  209. """取会话的用量明细(倒序)与会话累计压缩次数。"""
  210. rows = db_conn.query(
  211. "SELECT * FROM session_context_usage WHERE session_id = ?"
  212. " ORDER BY created_at DESC, id DESC LIMIT ?",
  213. (session_id, limit),
  214. )
  215. history = []
  216. for row in rows:
  217. item = dict(row)
  218. item["snapshot"] = _loads(item.get("snapshot"), {})
  219. item["compacted"] = bool(item.get("compacted"))
  220. history.append(item)
  221. compact_count = int(
  222. db_conn.scalar(
  223. "SELECT MAX(compact_count) FROM session_context_usage WHERE session_id = ?",
  224. (session_id,),
  225. 0,
  226. )
  227. or 0
  228. )
  229. latest = history[0] if history else None
  230. return {
  231. "session_id": session_id,
  232. "compact_count": compact_count,
  233. "latest": latest,
  234. "history": history,
  235. }
  236. def save_context_archive(
  237. session_id: str, turn: int, file_path: str, tokens: int = 0
  238. ) -> str:
  239. """登记一次「压缩前原文」的落盘位置,供事后追溯。"""
  240. row_id = db_conn.new_id()
  241. db_conn.execute(
  242. "INSERT INTO context_archives (id, session_id, turn, file_path, tokens, created_at)"
  243. " VALUES (?, ?, ?, ?, ?, ?)",
  244. (row_id, session_id, int(turn), file_path, int(tokens), db_conn.fmt_dt()),
  245. )
  246. return row_id
  247. def list_context_archives(session_id: str) -> list[dict[str, Any]]:
  248. """列出会话的压缩存档索引。"""
  249. return db_conn.query(
  250. "SELECT * FROM context_archives WHERE session_id = ? ORDER BY created_at ASC",
  251. (session_id,),
  252. )
  253. # ============================================================
  254. # 用户偏好
  255. # ============================================================
  256. def save_preference(user_name: str, key: str, value: str) -> None:
  257. """写入/覆盖一条用户偏好(先查后写,避免方言相关的 upsert 语法)。"""
  258. existing = db_conn.query_one(
  259. "SELECT id FROM user_preferences WHERE user_name = ? AND pref_key = ?",
  260. (user_name, key),
  261. )
  262. stamp = db_conn.fmt_dt()
  263. if existing:
  264. db_conn.execute(
  265. "UPDATE user_preferences SET pref_value = ?, updated_at = ? WHERE id = ?",
  266. (value, stamp, existing["id"]),
  267. )
  268. else:
  269. db_conn.execute(
  270. "INSERT INTO user_preferences (id, user_name, pref_key, pref_value, updated_at)"
  271. " VALUES (?, ?, ?, ?, ?)",
  272. (db_conn.new_id(), user_name, key, value, stamp),
  273. )
  274. def list_preferences(user_name: str) -> list[dict[str, Any]]:
  275. """列出某用户的全部偏好。"""
  276. return db_conn.query(
  277. "SELECT pref_key, pref_value, updated_at FROM user_preferences"
  278. " WHERE user_name = ? ORDER BY pref_key ASC",
  279. (user_name,),
  280. )
  281. def delete_preference(user_name: str, key: str) -> bool:
  282. """删除一条偏好,返回是否命中。"""
  283. return db_conn.execute(
  284. "DELETE FROM user_preferences WHERE user_name = ? AND pref_key = ?",
  285. (user_name, key),
  286. ) > 0
  287. def preferences_as_text(user_name: str, limit: int = 20) -> str:
  288. """把偏好渲染成可注入提示词的一行文本;无偏好返回空串。"""
  289. rows = list_preferences(user_name)[:limit]
  290. if not rows:
  291. return ""
  292. pairs = [f"{row['pref_key']}={row['pref_value']}" for row in rows]
  293. return ";".join(pairs)
  294. # ============================================================
  295. # 行归一化
  296. # ============================================================
  297. def _hydrate_session(row: dict[str, Any] | None) -> dict[str, Any] | None:
  298. if not row:
  299. return None
  300. session = dict(row)
  301. session["pinned"] = bool(session.get("pinned"))
  302. return session
  303. def _hydrate_message(row: dict[str, Any]) -> dict[str, Any]:
  304. message = dict(row)
  305. message["tool_calls"] = _loads(message.get("tool_calls"), [])
  306. message["citations"] = _loads(message.get("citations"), [])
  307. return message