Files
xiaoli-cli/xcli_core/session.py
T
shuiyu67andWorkBuddy 749fb3f91c release: v8.0.4 — 会话 SQLite 存储 + 去加载动画 + 展示截断 + 工具调用解析加固
- 会话数据改用 SQLite(chat_history/conversations.db,WAL):sessions / snapshots 两表,
  首次启动自动迁移旧 JSON,/chat 与 /resume 改走 SQLite,memory 搜索兼容数据库
- 去掉流式显示(_typeprint 改整段打印)与常驻加载动画(移除 show_animation 线程及逐帧输出)
- 展示给用户的消息过长自动截断(仅展示层,底层 messages 完整保存)
- 工具调用解析加固:_loads_json 四级兜底修复;新增 _extract_last_action_json 成对错括号扫描;
  续轮也下发 Function Calling 工具,减少长文本格式错误浪费 token
- 版本号统一更新至 8.0.4(README/about.txt/constants/TUI banner/LSP·MCP clientInfo)
- 12 项 session 单测 + 8 项 JSON 提取边界用例全部通过

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-08 00:54:18 +08:00

376 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""会话(Session)持久化与恢复 —— SQLite 存储后端。
把 openai/ollama/manual 等引擎的对话历史(self.shared_conversation_history)
按「会话」维度持久化到单一 SQLite 数据库 chat_history/conversations.db,
取代原先每个会话一个 JSON 文件的方式。两张逻辑表:
- sessions : 自动持久化的会话,供 /resume 使用
- snapshots : 用户命名的 /chat save 快照
兼容旧版 chat_history/<id>.json:首次打开历史目录时,自动把遗留的 *.json
(含旧 /chat save 的 conversation 格式)迁入库,并移到 _migrated_json/ 备份,
避免重复计数。
"""
import os
import re
import time
import json
import uuid
import sqlite3
import shutil
def _make_session_id() -> str:
"""生成形如 20260805-171045-a1b2c3 的会话 ID。"""
ts = time.strftime("%Y%m%d-%H%M%S")
suffix = uuid.uuid4().hex[:6]
return f"{ts}-{suffix}"
def _title_from_messages(messages, limit: int = 40) -> str:
"""从首条 user 消息生成会话标题。"""
for m in messages or []:
if isinstance(m, dict) and m.get("role") == "user":
content = m.get("content", "")
if isinstance(content, list): # 多模态 content 可能是列表
parts = [c.get("text", "") for c in content if isinstance(c, dict)]
content = " ".join(parts)
content = (content or "").strip().replace("\n", " ")
return content[:limit] if content else "(空消息)"
return "(未命名会话)"
class Session:
"""单个会话的轻量容器。"""
def __init__(self, sid, title, created_at, updated_at, engine, model, messages,
is_snapshot: bool = False):
self.id = sid
self.title = title
self.created_at = created_at
self.updated_at = updated_at
self.engine = engine
self.model = model
self.messages = messages or []
self.is_snapshot = is_snapshot # /chat save 快照在 /resume 里归并
@property
def msg_count(self) -> int:
return len(self.messages)
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"created_at": self.created_at,
"updated_at": self.updated_at,
"engine": self.engine,
"model": self.model,
"messages": self.messages,
}
@classmethod
def from_dict(cls, data: dict, fallback_id: str = ""):
sid = data.get("id") or fallback_id
messages = data.get("messages")
if messages is None and "conversation" in data:
messages = data["conversation"]
return cls(
sid=sid,
title=data.get("title") or _title_from_messages(messages),
created_at=data.get("created_at") or data.get("timestamp") or "",
updated_at=data.get("updated_at") or data.get("timestamp") or "",
engine=data.get("engine") or "",
model=data.get("model") or "",
messages=messages or [],
)
class SessionManager:
"""基于 SQLite 的会话读写与索引管理。"""
def __init__(self, history_dir: str):
self.history_dir = history_dir
if history_dir and not os.path.exists(history_dir):
try:
os.makedirs(history_dir)
except OSError:
pass
self.db_path = os.path.join(self.history_dir, "conversations.db") if history_dir else ":memory:"
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
try:
self._conn.execute("PRAGMA journal_mode=WAL")
except sqlite3.Error:
pass
self._init_schema()
self._migrate_legacy()
# ── 表结构 ──
def _init_schema(self):
c = self._conn.cursor()
c.execute(
"""CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY,
title TEXT,
created_at TEXT,
updated_at TEXT,
engine TEXT,
model TEXT,
messages TEXT
)"""
)
c.execute(
"""CREATE TABLE IF NOT EXISTS snapshots(
name TEXT PRIMARY KEY,
saved_at TEXT,
messages TEXT
)"""
)
self._conn.commit()
# ── 遗留 JSON 迁移(仅一次)──
def _migrate_legacy(self):
if not self.history_dir:
return
marker = os.path.join(self.history_dir, ".migrated")
if os.path.exists(marker):
return
try:
legacy = []
for fn in os.listdir(self.history_dir):
if not fn.endswith(".json"):
continue
if fn == "conversations.db":
continue
path = os.path.join(self.history_dir, fn)
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
continue
if not isinstance(data, dict):
continue
legacy.append((fn, data))
if legacy:
backup = os.path.join(self.history_dir, "_migrated_json")
os.makedirs(backup, exist_ok=True)
for fn, data in legacy:
src = os.path.join(self.history_dir, fn)
sid = data.get("id")
if sid and "messages" in data:
s = Session.from_dict(data, fallback_id=sid)
self._upsert_session_row(s)
elif "conversation" in data:
name = fn[:-5]
self.save_snapshot(
name, data["conversation"], saved_at=data.get("timestamp")
)
else:
continue
# 迁完后移走,避免被 list_sessions 的遗留扫描重复计数
try:
shutil.move(src, os.path.join(backup, fn))
except OSError:
pass
self._conn.commit()
try:
with open(marker, "w", encoding="utf-8") as f:
f.write("migrated")
except OSError:
pass
except Exception:
# 迁移失败绝不阻断启动
pass
# ── 新建 ──
def new_session(self, engine: str = "", model: str = ""):
now = time.strftime("%Y-%m-%d %H:%M:%S")
return Session(
sid=_make_session_id(),
title="(新会话)",
created_at=now,
updated_at=now,
engine=engine or "",
model=model or "",
messages=[],
)
# ── 落库辅助 ──
def _upsert_session_row(self, s: Session):
self._conn.execute(
"""INSERT OR REPLACE INTO sessions
(id, title, created_at, updated_at, engine, model, messages)
VALUES (?,?,?,?,?,?,?)""",
(s.id, s.title, s.created_at, s.updated_at, s.engine, s.model,
json.dumps(s.messages, ensure_ascii=False, default=str)),
)
self._conn.commit()
# ── 保存 ──
def save(self, session: Session, messages, engine: str = "", model: str = "",
updated_at: str = None) -> bool:
"""把当前对话内容写回到会话表。updated_at 可覆盖(测试/迁移用)。"""
if session is None:
return False
session.messages = list(messages or [])
if not session.engine and engine:
session.engine = engine
if not session.model and model:
session.model = model
if session.title in ("(新会话)", "(未命名会话)", ""):
session.title = _title_from_messages(session.messages)
now = updated_at or time.strftime("%Y-%m-%d %H:%M:%S")
session.updated_at = now
if not session.created_at:
session.created_at = now
try:
self._upsert_session_row(session)
return True
except sqlite3.Error:
return False
# ── 列出 ──
def list_sessions(self) -> list:
"""返回按更新时间倒序的 Session 列表(sessions ∪ snapshots ∪ 遗留 JSON)。"""
sessions = []
try:
rows = self._conn.execute(
"SELECT id, title, created_at, updated_at, engine, model, messages "
"FROM sessions ORDER BY updated_at DESC"
).fetchall()
for r in rows:
msgs = self._safe_messages(r["messages"])
sessions.append(Session(
sid=r["id"], title=r["title"], created_at=r["created_at"],
updated_at=r["updated_at"], engine=r["engine"], model=r["model"],
messages=msgs,
))
except sqlite3.Error:
pass
# 合并 /chat save 快照(向后兼容:旧版里它们也出现在 /resume 列表)
for snap in self._iter_snapshots():
sessions.append(snap)
# 合并目录里残留的遗留 JSON(迁移遗漏或非本工具写入)
for legacy in self._iter_legacy_json():
# 去重:已入库的同名不重复
if any(s.id == legacy.id for s in sessions):
continue
sessions.append(legacy)
sessions.sort(key=lambda s: s.updated_at, reverse=True)
return sessions
# ── 按 id 或序号解析 ──
def resolve(self, token: str, sessions: list = None) -> Session:
"""token 可以是会话 id(含文件名去后缀)或列表序号(从1)。"""
if sessions is None:
sessions = self.list_sessions()
token = (token or "").strip()
if token.isdigit():
idx = int(token) - 1
if 0 <= idx < len(sessions):
return sessions[idx]
for s in sessions:
if s.id == token or s.id.startswith(token):
return s
if token.endswith(".json"):
token = token[:-5]
for s in sessions:
if s.id == token:
return s
return None
def load(self, token: str) -> Session:
"""读取指定会话的完整内容(含 messages)。"""
return self.resolve(token)
# ── 快照(/chat save)──
def save_snapshot(self, name: str, messages, saved_at: str = None) -> bool:
try:
self._conn.execute(
"INSERT OR REPLACE INTO snapshots (name, saved_at, messages) VALUES (?,?,?)",
(name, saved_at or time.strftime("%Y-%m-%d %H:%M:%S"),
json.dumps(list(messages or []), ensure_ascii=False, default=str)),
)
self._conn.commit()
return True
except sqlite3.Error:
return False
def list_snapshots(self) -> list:
"""返回 [(name, saved_at), ...] 倒序。"""
try:
rows = self._conn.execute(
"SELECT name, saved_at FROM snapshots ORDER BY saved_at DESC"
).fetchall()
return [(r["name"], r["saved_at"]) for r in rows]
except sqlite3.Error:
return []
def load_snapshot(self, name: str):
"""返回快照的消息列表,或 None。"""
try:
row = self._conn.execute(
"SELECT messages FROM snapshots WHERE name=?", (name,)
).fetchone()
if row is None:
return None
return self._safe_messages(row["messages"])
except sqlite3.Error:
return None
# ── 内部辅助 ──
@staticmethod
def _safe_messages(text):
try:
data = json.loads(text or "[]")
return data if isinstance(data, list) else []
except (json.JSONDecodeError, ValueError, TypeError):
return []
def _iter_snapshots(self):
try:
rows = self._conn.execute(
"SELECT name, saved_at, messages FROM snapshots"
).fetchall()
except sqlite3.Error:
return
for r in rows:
msgs = self._safe_messages(r["messages"])
yield Session(
sid=r["name"], title=_title_from_messages(msgs),
created_at=r["saved_at"], updated_at=r["saved_at"],
engine="", model="", messages=msgs, is_snapshot=True,
)
def _iter_legacy_json(self):
if not self.history_dir:
return
skip = {"conversations.db", ".migrated"}
for fn in os.listdir(self.history_dir):
if not fn.endswith(".json"):
continue
if fn in skip:
continue
path = os.path.join(self.history_dir, fn)
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
continue
if not isinstance(data, dict):
continue
sid = data.get("id")
if sid and "messages" in data:
yield Session.from_dict(data, fallback_id=sid)
elif "conversation" in data:
msgs = data["conversation"]
yield Session(
sid=fn[:-5], title=_title_from_messages(msgs),
created_at=data.get("timestamp", ""), updated_at=data.get("timestamp", ""),
engine="", model="", messages=msgs, is_snapshot=True,
)