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>
This commit is contained in:
1 parent
66de8eea13
commit
749fb3f91c
15 files changed
+510
-249
No files matched your search
+232
-58
@@ -1,30 +1,22 @@
|
||||
"""会话(Session)持久化与恢复。
|
||||
"""会话(Session)持久化与恢复 —— SQLite 存储后端。
|
||||
|
||||
把 openai/ollama/manual 等引擎的对话历史(self.shared_conversation_history)
|
||||
按「会话」维度持久化到 chat_history/<id>.json,支持:
|
||||
- 自动落盘(每轮对话后调用 SessionManager.save)
|
||||
- 列出历史会话(/resume、/sessions)
|
||||
- 一键恢复(把 messages 重新载入 shared_conversation_history)
|
||||
按「会话」维度持久化到单一 SQLite 数据库 chat_history/conversations.db,
|
||||
取代原先每个会话一个 JSON 文件的方式。两张逻辑表:
|
||||
- sessions : 自动持久化的会话,供 /resume 使用
|
||||
- snapshots : 用户命名的 /chat save 快照
|
||||
|
||||
存储格式(新):
|
||||
{
|
||||
"id": "20260805-171045-a1b2c3",
|
||||
"title": "用户首条消息前若干个字",
|
||||
"created_at": "2026-08-05 17:10:45",
|
||||
"updated_at": "2026-08-05 17:15:22",
|
||||
"engine": "openai",
|
||||
"model": "xophunyuan7bmt",
|
||||
"messages": [ ... ]
|
||||
}
|
||||
|
||||
兼容旧 /chat save 写入的文件(无 id 字段、用 "conversation" 键):
|
||||
读取时回退——id 取文件名,messages 取 conversation 键。
|
||||
兼容旧版 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:
|
||||
@@ -50,7 +42,8 @@ def _title_from_messages(messages, limit: int = 40) -> str:
|
||||
class Session:
|
||||
"""单个会话的轻量容器。"""
|
||||
|
||||
def __init__(self, sid, title, created_at, updated_at, engine, model, messages):
|
||||
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
|
||||
@@ -58,6 +51,7 @@ class Session:
|
||||
self.engine = engine
|
||||
self.model = model
|
||||
self.messages = messages or []
|
||||
self.is_snapshot = is_snapshot # /chat save 快照在 /resume 里归并
|
||||
|
||||
@property
|
||||
def msg_count(self) -> int:
|
||||
@@ -76,7 +70,6 @@ class Session:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict, fallback_id: str = ""):
|
||||
# 兼容旧 /chat save 格式:无 id、用 "conversation" 键
|
||||
sid = data.get("id") or fallback_id
|
||||
messages = data.get("messages")
|
||||
if messages is None and "conversation" in data:
|
||||
@@ -93,7 +86,7 @@ class Session:
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""会话的读写与索引管理。"""
|
||||
"""基于 SQLite 的会话读写与索引管理。"""
|
||||
|
||||
def __init__(self, history_dir: str):
|
||||
self.history_dir = history_dir
|
||||
@@ -102,6 +95,93 @@ class SessionManager:
|
||||
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 = ""):
|
||||
@@ -116,9 +196,21 @@ class SessionManager:
|
||||
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 = ""):
|
||||
"""把当前对话内容写回到 session 对应的文件。"""
|
||||
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 [])
|
||||
@@ -126,39 +218,48 @@ class SessionManager:
|
||||
session.engine = engine
|
||||
if not session.model and model:
|
||||
session.model = model
|
||||
# 首条用户消息生成标题(仅当仍是占位标题时)
|
||||
if session.title in ("(新会话)", "(未命名会话)", ""):
|
||||
session.title = _title_from_messages(session.messages)
|
||||
session.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
now = updated_at or time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
session.updated_at = now
|
||||
if not session.created_at:
|
||||
session.created_at = session.updated_at
|
||||
file_path = os.path.join(self.history_dir, f"{session.id}.json")
|
||||
session.created_at = now
|
||||
try:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2, default=str)
|
||||
self._upsert_session_row(session)
|
||||
return True
|
||||
except OSError:
|
||||
except sqlite3.Error:
|
||||
return False
|
||||
|
||||
# ── 列出 ──
|
||||
def list_sessions(self) -> list:
|
||||
"""返回按更新时间倒序的 Session 列表。"""
|
||||
"""返回按更新时间倒序的 Session 列表(sessions ∪ snapshots ∪ 遗留 JSON)。"""
|
||||
sessions = []
|
||||
try:
|
||||
files = [f for f in os.listdir(self.history_dir) if f.endswith(".json")]
|
||||
except OSError:
|
||||
return sessions
|
||||
for f in files:
|
||||
path = os.path.join(self.history_dir, f)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
sid = data.get("id") or f[:-5]
|
||||
s = Session.from_dict(data, fallback_id=sid)
|
||||
# 跳过明显空会话(无消息且非手动命名)
|
||||
sessions.append(s)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
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
|
||||
|
||||
@@ -168,16 +269,13 @@ class SessionManager:
|
||||
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]
|
||||
# id(精确或前缀)
|
||||
for s in sessions:
|
||||
if s.id == token or s.id.startswith(token):
|
||||
return s
|
||||
# 文件名(去掉 .json)
|
||||
if token.endswith(".json"):
|
||||
token = token[:-5]
|
||||
for s in sessions:
|
||||
@@ -187,15 +285,91 @@ class SessionManager:
|
||||
|
||||
def load(self, token: str) -> Session:
|
||||
"""读取指定会话的完整内容(含 messages)。"""
|
||||
sessions = self.list_sessions()
|
||||
s = self.resolve(token, sessions)
|
||||
if s is None:
|
||||
return None
|
||||
# 重新从文件读取以确保 messages 完整
|
||||
path = os.path.join(self.history_dir, f"{s.id}.json")
|
||||
return self.resolve(token)
|
||||
|
||||
# ── 快照(/chat save)──
|
||||
def save_snapshot(self, name: str, messages, saved_at: str = None) -> bool:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return Session.from_dict(data, fallback_id=s.id)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return s
|
||||
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,
|
||||
)
|
||||
Reference in new issue
Block a user