import sqlite3 import os import sys import re import json import uuid import time import shutil from typing import List, Dict, Optional # 🝙 P0 fix: DB path based on the file's location (no longer depends on the process's working directory) # 🌟 打包(PyInstaller onedir)时:优先与源码树共用 data/chat_history.db(历史不丢失); # 若 exe 被复制到源码树之外 → 回退为 exe 旁的 data/chat_history.db if getattr(sys, "frozen", False): _exe_dir = os.path.dirname(os.path.abspath(sys.executable)) _shared = os.path.abspath(os.path.join(_exe_dir, "..", "..", "data", "chat_history.db")) if os.path.isdir(os.path.dirname(_shared)): _DEFAULT_DB = _shared else: _DEFAULT_DB = os.path.join(_exe_dir, "data", "chat_history.db") else: _DEFAULT_DB = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "data", "chat_history.db") class _AutoCloseConn: """🝙 P0 fix: wraps sqlite3.Connection. sqlite3.Connection's with only handles commit/rollback and does NOT close. All 19 call sites use `with self.get_connection() as conn:`, so we auto-close on with exit. """ def __init__(self, conn): self._conn = conn def __getattr__(self, name): return getattr(self._conn, name) def __enter__(self): self._conn.__enter__() return self def __exit__(self, exc_type, exc, tb): try: return self._conn.__exit__(exc_type, exc, tb) finally: try: self._conn.close() except Exception: pass class DBManager: def __init__(self, db_path: str = None): # 🝙 Default path is anchored to the core/ directory, not affected by launch CWD self.db_path = db_path or os.path.abspath(_DEFAULT_DB) # 🆕 附件/媒体文件根目录(copy_session 深拷贝磁盘文件用)。默认=项目根;测试可覆盖。 self.files_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) self.is_first_run = not os.path.exists(self.db_path) d = os.path.dirname(self.db_path) if d: os.makedirs(d, exist_ok=True) self._init_db() def get_connection(self): conn = sqlite3.connect(self.db_path, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return _AutoCloseConn(conn) def _init_db(self): """初始化表结构并注入默认数据""" with self.get_connection() as conn: cursor = conn.cursor() # 1. 创建 sessions 表 (新增 current_leaf_msg_id) cursor.execute(""" CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER, has_messages BOOLEAN DEFAULT 0, sort_order INTEGER DEFAULT 0, is_starred BOOLEAN DEFAULT 0, current_leaf_msg_id TEXT ) """) # 2. 创建 messages 表 (新增 parent_id) cursor.execute(""" CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER, attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT, stop_reason TEXT, error_message TEXT, FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE ) """) # ========================================== # 🌟 核心性能优化:为高频查询的字段建立索引 # ========================================== cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_id ON messages(session_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_parent_id ON messages(parent_id)") # 3. 🌟 自动化热升级:检测并兼容旧数据库 self._upgrade_schema(cursor) # 4. 检查是否需要插入初始默认对话 cursor.execute("SELECT COUNT(*) FROM sessions") if cursor.fetchone()[0] == 0: self._seed_default_chat(cursor) conn.commit() def _upgrade_schema(self, cursor): """检测缺少的新字段并自动补齐,如果是刚升级,则自动将旧线性数据串联成链表""" upgraded = False # 兼容 sessions 字段 for col in ["has_messages", "sort_order", "is_starred"]: try: cursor.execute(f"SELECT {col} FROM sessions LIMIT 1") except sqlite3.OperationalError: if col == "has_messages": cursor.execute("ALTER TABLE sessions ADD COLUMN has_messages BOOLEAN DEFAULT 0") if col == "sort_order": cursor.execute("ALTER TABLE sessions ADD COLUMN sort_order INTEGER DEFAULT 0") if col == "is_starred": cursor.execute("ALTER TABLE sessions ADD COLUMN is_starred BOOLEAN DEFAULT 0") # 🌟 核心:兼容链表树架构 try: # 兼容 sessions.mode 列(chat/worker 模式锁定,NULL=未发送过) try: cursor.execute("SELECT mode FROM sessions LIMIT 1") except Exception: cursor.execute("ALTER TABLE sessions ADD COLUMN mode TEXT") cursor.execute("SELECT current_leaf_msg_id FROM sessions LIMIT 1") except sqlite3.OperationalError: print("[DB]: 正在升级 sessions 表 (添加 current_leaf_msg_id)...") cursor.execute("ALTER TABLE sessions ADD COLUMN current_leaf_msg_id TEXT") upgraded = True try: cursor.execute("SELECT attachment_metadata, parent_id FROM messages LIMIT 1") except sqlite3.OperationalError: print("[DB]: 正在升级 messages 表 (添加 attachment_metadata, parent_id)...") try: cursor.execute("ALTER TABLE messages ADD COLUMN attachment_metadata TEXT") except: pass try: cursor.execute("ALTER TABLE messages ADD COLUMN parent_id TEXT") except: pass upgraded = True # 🌟 messages.timeline 列(agent 时间线 JSON: 思考/文本/工具 按事件顺序) try: cursor.execute("SELECT timeline FROM messages LIMIT 1") except sqlite3.OperationalError: print("[DB]: 正在升级 messages 表 (添加 timeline)...") try: cursor.execute("ALTER TABLE messages ADD COLUMN timeline TEXT") except: pass upgraded = True # 🆕 P1: messages.usage 列(assistant 回复的精确 usage JSON, # 供显示/压缩估算做 usage 锚定,对照 pi 内存态 usage 回放) try: cursor.execute("SELECT usage FROM messages LIMIT 1") except sqlite3.OperationalError: print("[DB]: 正在升级 messages 表 (添加 usage)...") try: cursor.execute("ALTER TABLE messages ADD COLUMN usage TEXT") except: pass upgraded = True # 🆕 失败轮次持久化(对照 pi: message_end 无条件入库): # messages.stop_reason / error_message —— 区分「正常行 / 出错行」, # 供 UI 展示与 build_api_context 回放决策(NULL = 正常) # # ⚠️⚠️ 绝不能置 upgraded=True:该标志会触发下方的「旧数据链表化重构」, # 把用户的**树状分支拍平成线性链**(数据破坏)! # 纯追加列对本迁移自身而言是安全的,与旧库结构修复无关。 try: cursor.execute("SELECT stop_reason, error_message FROM messages LIMIT 1") except sqlite3.OperationalError: print("[DB]: 正在升级 messages 表 (添加 stop_reason, error_message)...") try: cursor.execute("ALTER TABLE messages ADD COLUMN stop_reason TEXT") except: pass try: cursor.execute("ALTER TABLE messages ADD COLUMN error_message TEXT") except: pass # 如果刚才执行了树状结构升级,立即对旧数据进行“时间线串联”修复 if upgraded: print("[DB]: 🚀 正在执行旧数据链表化重构...") cursor.execute("SELECT id FROM sessions") sessions = cursor.fetchall() for s in sessions: sid = s['id'] cursor.execute("SELECT id FROM messages WHERE session_id = ? ORDER BY created_at ASC", (sid,)) msgs = cursor.fetchall() if not msgs: continue # 遍历消息,将后一条的 parent_id 指向上一条 prev_id = None for m in msgs: mid = m['id'] if prev_id: cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?", (prev_id, mid)) prev_id = mid # 最后一个 msg_id 就是这棵树的末端叶子节点 cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, sid)) print("[DB]: ✅ 数据结构完美升级为链表树!") def _seed_default_chat(self, cursor): session_id = f"sess_{uuid.uuid4().hex[:12]}" now = int(time.time()) cursor.execute( "INSERT INTO sessions (id, title, created_at, updated_at, has_messages) VALUES (?, ?, ?, ?, ?)", (session_id, "让我们从这里开始吧", now, now, 1) ) sys_id = f"msg_sys_init" cursor.execute(""" INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (sys_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None)) default_messages = [ {"role": "user", "content": "你好呀,你是谁?"}, {"role": "assistant", "content": "嗨!我是你的 AI 助手 ✨ ..."}, {"role": "user", "content": "那你到底能帮我做什么?"}, {"role": "assistant", "content": "简单来说,能打字问的我都聊..."} ] prev_id = sys_id for msg in default_messages: msg_id = f"msg_{uuid.uuid4().hex[:16]}" now += 1 cursor.execute(""" INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (msg_id, session_id, msg["role"], msg["content"], "", 0, now, prev_id)) prev_id = msg_id # 设置默认会话的叶子节点 cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, session_id)) # ==================== 会话 (Session) 操作 ==================== def get_session_mode(self, session_id: str) -> Optional[str]: """读取会话锁定的模式(chat/worker),未发送过返回 None""" with self.get_connection() as conn: row = conn.execute("SELECT mode FROM sessions WHERE id = ?", (session_id,)).fetchone() return row["mode"] if row else None def set_session_mode(self, session_id: str, mode: str): """锁定会话模式(首条消息发送时调用,之后不可变)""" with self.get_connection() as conn: conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", (mode, session_id)) def get_all_sessions(self) -> List[Dict]: with self.get_connection() as conn: return [dict(row) for row in conn.execute("SELECT * FROM sessions ORDER BY is_starred DESC, sort_order ASC, updated_at DESC").fetchall()] def create_session(self, title: str = "新对话") -> Dict: session_id = f"sess_{uuid.uuid4().hex[:12]}" now = int(time.time()) sys_msg_id = f"msg_{uuid.uuid4().hex[:16]}" with self.get_connection() as conn: cursor = conn.cursor() min_order = self.get_min_sort_order() # 创建 session,直接将 system 消息设为初始叶子节点 cursor.execute( "INSERT INTO sessions (id, title, created_at, updated_at, has_messages, sort_order, current_leaf_msg_id) VALUES (?, ?, ?, ?, ?, ?, ?)", (session_id, title, now, now, 0, min_order, sys_msg_id) ) cursor.execute(""" INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (sys_msg_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None)) conn.commit() return dict(cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()) # ------------------------------------------------------------------ # 🆕 会话复制:深度克隆(全部分支 + 压缩标记 + 附件文件) # ------------------------------------------------------------------ def _make_copy_title(self, base: str) -> str: """生成不重名的副本标题:X → X (副本) → X (副本 2) → …""" root = re.sub(r"\s*\(副本(?:\s*\d+)?\)\s*$", "", base or "").strip() or "新对话" with self.get_connection() as conn: existing = {r[0] for r in conn.execute("SELECT title FROM sessions").fetchall()} cand = f"{root} (副本)" n = 2 while cand in existing and n < 1000: cand = f"{root} (副本 {n})" n += 1 return cand def _dup_media_file(self, old_path: str, prefix: str): """把一个项目内媒体文件复制成新名字。 返回 (新绝对路径, 新项目相对路径);文件不存在或异常返回 None。""" if not old_path: return None old_abs = old_path if os.path.isabs(old_path) else os.path.join(self.files_root, old_path) if not os.path.isfile(old_abs): return None ext = os.path.splitext(old_abs)[1] or ".bin" new_dir = os.path.join(self.files_root, "data", "attachments") os.makedirs(new_dir, exist_ok=True) new_abs = "" for _ in range(5): new_abs = os.path.join(new_dir, f"{prefix}_{uuid.uuid4().hex[:8]}{ext}") if not os.path.exists(new_abs): break shutil.copy2(old_abs, new_abs) new_rel = os.path.relpath(new_abs, self.files_root).replace("\\", "/") return new_abs, new_rel def _copy_attachment_files(self, meta_json: Optional[str]) -> Optional[str]: """附件深拷贝:image/pdf 的磁盘文件复制改名并改写 local_path; text 类型正文内联在 JSON 里,无需处理。 任何异常/文件缺失都原样返回,绝不让复制整体失败。""" if not meta_json: return meta_json try: meta = json.loads(meta_json) except Exception: return meta_json if not isinstance(meta, dict) or not meta.get("attachments"): return meta_json changed = False for att in meta.get("attachments") or []: if not isinstance(att, dict): continue try: if att.get("type") in ("image", "pdf") and att.get("local_path"): got = self._dup_media_file( att["local_path"], "img" if att["type"] == "image" else "pdf") if got: att["local_path"] = got[1] if "abs_path" in att: att["abs_path"] = got[0] changed = True if att.get("type") == "pdf": for im in att.get("images") or []: if not isinstance(im, dict): continue got = self._dup_media_file( im.get("abs_path") or im.get("local_path"), "pdfimg") if got: im["abs_path"] = got[0] im["local_path"] = got[1] changed = True except Exception as e: print(f"[DB] ⚠️ 附件深拷贝失败(保留原路径): {e}", flush=True) return json.dumps(meta, ensure_ascii=False) if changed else meta_json def copy_session(self, session_id: str, new_title: Optional[str] = None, copy_attachments: bool = True) -> Optional[Dict]: """📋 深度复制一个会话。 - messages 全部重新生成 ID,parent_id / current_leaf_msg_id 全量重映射 → 分支、压缩标记(role='compaction')都原样保留 - image/pdf 附件文件物理复制成新文件 → 副本自包含,删任意一方不影响另一方 - 单事务写入;源会话零改动 返回新会话 dict;源不存在返回 None。 """ now = int(time.time()) n_att = 0 with self.get_connection() as conn: cursor = conn.cursor() src_row = cursor.execute( "SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() if not src_row: return None src = dict(src_row) rows = [dict(r) for r in cursor.execute( "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC", (session_id,)).fetchall()] # ① 新 ID 映射(保持 comp_ / msg_ 前缀约定) idmap = {} for m in rows: pre = "comp_" if m.get("role") == "compaction" else "msg_" idmap[m["id"]] = f"{pre}{uuid.uuid4().hex[:16]}" # ② 标题(重名自动递增) title = new_title or self._make_copy_title(src.get("title") or "新对话") # ③ 消息 created_at 保相对间隔(同层兄弟排序不变) t0 = min((m.get("created_at") or 0) for m in rows) if rows else now new_sid = f"sess_{uuid.uuid4().hex[:12]}" new_leaf = idmap.get(src.get("current_leaf_msg_id")) if rows and not new_leaf: # 兜底:源叶子不在链上(数据异常)→ 取副本里时间最新的一条 last = max(rows, key=lambda m: (m.get("created_at") or 0)) new_leaf = idmap.get(last["id"]) print(f"[DB] ⚠️ copy_session 源叶子异常,回退 leaf={new_leaf}", flush=True) # ④ 新会话(置列表顶部、不带星标、模式跟随源) cursor.execute( "INSERT INTO sessions (id, title, created_at, updated_at, has_messages, " "sort_order, is_starred, current_leaf_msg_id, mode) VALUES (?,?,?,?,?,?,?,?,?)", (new_sid, title, now, now, src.get("has_messages") or 0, self.get_min_sort_order(), 0, new_leaf, src.get("mode"))) # ⑤ 逐条复制消息(parent 重映射 + 附件深拷贝) for m in rows: meta = m.get("attachment_metadata") if copy_attachments and meta: new_meta = self._copy_attachment_files(meta) if new_meta != meta: n_att += 1 meta = new_meta cursor.execute( "INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, " "created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message) " "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (idmap[m["id"]], new_sid, m.get("role"), m.get("content"), m.get("reasoning"), m.get("is_ignored"), now + ((m.get("created_at") or 0) - t0), meta, idmap.get(m.get("parent_id")), m.get("timeline"), m.get("usage"), m.get("stop_reason"), m.get("error_message"))) conn.commit() out = dict(cursor.execute( "SELECT * FROM sessions WHERE id = ?", (new_sid,)).fetchone()) try: print(f"[DB] copy_session {session_id[:8]} → {new_sid[:8]} " f"消息={len(rows)} 附件深拷贝={n_att} 标题={title}", flush=True) except Exception: pass return out def update_session_title(self, session_id: str, new_title: str): with self.get_connection() as conn: conn.execute("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?", (new_title, int(time.time()), session_id)) conn.commit() def delete_session(self, session_id: str): with self.get_connection() as conn: conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) conn.commit() # 下方其它基本Session功能保持不变... def mark_session_has_messages(self, session_id: str): with self.get_connection() as conn: conn.execute("UPDATE sessions SET has_messages = 1 WHERE id = ?", (session_id,)) conn.commit() def check_session_needs_title(self, session_id: str) -> bool: with self.get_connection() as conn: row = conn.execute("SELECT title, has_messages FROM sessions WHERE id = ?", (session_id,)).fetchone() if row: return row[0] == "新对话" and row[1] == 1 return False def get_min_sort_order(self) -> int: with self.get_connection() as conn: row = conn.execute("SELECT MIN(sort_order) FROM sessions").fetchone() return (row[0] or 0) - 1 def update_session_order(self, ordered_ids: list): with self.get_connection() as conn: for idx, sid in enumerate(ordered_ids): conn.execute("UPDATE sessions SET sort_order = ? WHERE id = ?", (idx, sid)) conn.commit() def update_session_star(self, session_id: str, is_starred: bool): with self.get_connection() as conn: conn.execute("UPDATE sessions SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if is_starred else 0, int(time.time()), session_id)) conn.commit() def is_session_starred(self, session_id: str) -> bool: with self.get_connection() as conn: row = conn.execute("SELECT is_starred FROM sessions WHERE id = ?", (session_id,)).fetchone() return bool(row and row[0]) # ==================== 🌟 核心:消息链表树操作 ==================== def get_session_leaf(self, session_id: str) -> Optional[str]: """获取会话当前的叶子节点ID""" with self.get_connection() as conn: row = conn.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,)).fetchone() return row[0] if row else None def update_session_leaf(self, session_id: str, leaf_msg_id: str): """切换时间线:手动更新当前会话的叶子节点""" with self.get_connection() as conn: conn.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?", (leaf_msg_id, int(time.time()), session_id)) conn.commit() def get_message_chain(self, session_id: str) -> List[Dict]: """🚀 极客级递归拉取:顺藤摸瓜,只返回当前激活时间线上的消息!彻底断绝下游污染!""" leaf_id = self.get_session_leaf(session_id) if not leaf_id: return [] chain = [] with self.get_connection() as conn: cursor = conn.cursor() current_id = leaf_id seen = set() # 🐛 环检测:数据异常(如自环标记)时快速退出,防主线程死循环卡死 # 使用 Python 循环向上回溯(比 SQL CTE 更好调试,性能对于本地几千条聊天来说在 1ms 内) while current_id and current_id not in seen: seen.add(current_id) cursor.execute("SELECT * FROM messages WHERE id = ?", (current_id,)) msg = cursor.fetchone() if not msg: break chain.append(dict(msg)) current_id = msg['parent_id'] if current_id in seen: try: print(f"[DB] ⚠️ get_message_chain 检测到环(session={session_id}),已截断", flush=True) except Exception: pass # 因为是向上回溯,拉出来的链条是反的,最后翻转一下恢复正序 chain.reverse() return chain def get_branch_info(self, parent_id: str) -> List[Dict]: """获取某一父节点下的所有子分支消息 (第二阶段用于UI渲染 '1/3')""" if not parent_id: return [] with self.get_connection() as conn: return [dict(row) for row in conn.execute( "SELECT * FROM messages WHERE parent_id = ? ORDER BY created_at ASC", (parent_id,) ).fetchall()] def add_message(self, session_id: str, role: str, content: str, parent_id: str, reasoning: str = "", is_ignored: bool = False, msg_id: Optional[str] = None, attachment_metadata: Optional[str] = None, timeline: Optional[str] = None, usage: Optional[str] = None, stop_reason: Optional[str] = None, error_message: Optional[str] = None) -> Dict: """添加新消息,并自动将该消息设为当前会话的最新叶子节点 🆕 P1: usage —— assistant 回复的精确 usage JSON(如 '{"input":..,"output":..}') 🆕 失败轮次: stop_reason/error_message —— 'error' 行入库但不回退叶子 (对照 pi:错误也持久化,回放时由 build_api_context 决定取舍)""" if not msg_id: msg_id = f"msg_{uuid.uuid4().hex[:16]}" now = int(time.time()) with self.get_connection() as conn: cursor = conn.cursor() # 1. 插入消息 cursor.execute(""" INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (msg_id, session_id, role, content, reasoning, 1 if is_ignored else 0, now, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message)) # 2. 自动更新 session 的叶子节点(时间线前推) cursor.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?", (msg_id, now, session_id)) conn.commit() try: print(f"[DB] add_message role={role} 内容={len(content or '')}c " f"思考={len(reasoning or '')}c 时间线={'有' if timeline else '无'} " f"id={msg_id} session={session_id[:8]}", flush=True) except Exception: pass return dict(cursor.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone()) # ------------------------------------------------------------------ # 🆕 压缩持久化:链上标记点(子叶子回溯到标记即停) # 插入后链形:…→ cut_before → [MARK role="compaction"] → first_retained → …→叶子 # 历史行全部保留(前端切会话渲染照常);API 上下文构建在标记处截断。 # 关键:不动 sessions.current_leaf_msg_id(绝不能用 add_message); # INSERT + UPDATE 同一事务,不留断链窗口。 # ------------------------------------------------------------------ def insert_compaction_mark(self, session_id: str, summary: str, cut_before_id: str, first_retained_id: str, meta_json: Optional[str] = None) -> Optional[str]: """在链上插入压缩标记行并把保留首条的 parent_id 改指到标记。返回 mark_id。""" if not cut_before_id or not first_retained_id: return None if cut_before_id == first_retained_id: # 🐛 防自环:同一行不能既做切点前又做保留首条(timeline 回放同 id 场景) try: print(f"[DB] ⚠️ insert_compaction_mark 拒绝自环 cut==retained={cut_before_id}", flush=True) except Exception: pass return None mark_id = f"comp_{uuid.uuid4().hex[:16]}" now = int(time.time()) with self.get_connection() as conn: cursor = conn.cursor() cursor.execute("""INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, attachment_metadata, parent_id, timeline, usage) VALUES (?, ?, 'compaction', ?, '', 1, ?, ?, ?, NULL, NULL)""", (mark_id, session_id, summary, now, meta_json, cut_before_id)) cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?", (mark_id, first_retained_id)) conn.commit() try: print(f"[DB] insert_compaction_mark session={session_id[:8]} " f"cut_before={cut_before_id} first_retained={first_retained_id} " f"mark={mark_id} summary={len(summary or '')}c", flush=True) except Exception: pass return mark_id # === 在 db_manager.py 中添加这个方法 === def get_branch_leaf(self, msg_id: str) -> str: """寻找一条时间线的最末端叶子节点""" current_id = msg_id with self.get_connection() as conn: cursor = conn.cursor() while True: # 寻找把当前节点作为父节点的子节点,按时间倒序取最新的一条(意味着它顺着最近被聊过的那条线往下走) cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (current_id,)) child = cursor.fetchone() if child: current_id = child[0] else: break # 没有子节点了,它自己就是叶子! return current_id def delete_message_branch(self, session_id: str, msg_id: str): """🚀 精准剪枝:删AI只删当前分支,删User连根拔起,并自动平滑回退时间线""" with self.get_connection() as conn: cursor = conn.cursor() # 1. 查出要删除的节点的父亲 cursor.execute("SELECT parent_id FROM messages WHERE id = ?", (msg_id,)) row = cursor.fetchone() if not row: return safe_parent_id = row['parent_id'] # 2. 目标就是传进来的 msg_id 本身 (不再强制上移到 parent) target_id = msg_id # 3. 拉取全会话,构建亲属关系树 cursor.execute("SELECT id, parent_id FROM messages WHERE session_id = ?", (session_id,)) all_msgs = cursor.fetchall() children_map = {} for m in all_msgs: pid = m['parent_id'] if pid not in children_map: children_map[pid] = [] children_map[pid].append(m['id']) # 4. 广度优先搜索 (BFS):找出目标消息及其所有子孙 to_delete = set([target_id]) queue = [target_id] while queue: curr = queue.pop(0) if curr in children_map: for child in children_map[curr]: if child not in to_delete: to_delete.add(child) queue.append(child) # 5. 判断当前时间线的“叶子节点”是否在被波及的名单里 cursor.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,)) leaf_row = cursor.fetchone() leaf_needs_update = leaf_row and leaf_row['current_leaf_msg_id'] in to_delete # 6. 🌟 执行物理删除前,收集将被删除的附件元数据 deleted_metadata = [] for d_id in to_delete: # 先查出它的 metadata cursor.execute("SELECT attachment_metadata FROM messages WHERE id = ?", (d_id,)) row = cursor.fetchone() if row and row['attachment_metadata']: deleted_metadata.append(row['attachment_metadata']) # 然后再执行物理删除 cursor.execute("DELETE FROM messages WHERE id = ?", (d_id,)) # 7. 🌟 核心:如果时间线断了,自动寻找平滑降落点 sibling_row = None if leaf_needs_update: # 尝试寻找被删节点的最新“兄弟姐妹” (例如删了分支2,寻找分支1) cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (safe_parent_id,)) sibling_row = cursor.fetchone() # 如果有兄弟,降落到兄弟;如果没兄弟(只有1次回答),退回原点(提问) new_leaf = sibling_row['id'] if sibling_row else safe_parent_id cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (new_leaf, session_id)) conn.commit() # 8. 如果降落到了兄弟分支,兄弟可能还有下文,需再次对齐真实叶子节点 if leaf_needs_update and sibling_row: real_leaf = self.get_branch_leaf(new_leaf) self.update_session_leaf(session_id, real_leaf) return deleted_metadata # 🌟 返回被删除的元数据,交给 MainWindow 去粉碎文件