Files
xiaoli-cli/xcli_core/session.py
T
shuiyu67 07490c4d38 feat: opencode 式会话持久化 + /resume 一键恢复
此前只有手动的 /chat save|list|open,关终端就丢上下文。现升级为
opencode 式体验:每轮对话自动落盘、启动提示历史会话、/resume 一键恢复。

- 新增 xcli_core/session.py:Session 数据类(id/title/created_at/updated_at/
  engine/model/messages)+ SessionManager(new/save/list/load/resolve,存
  chat_history/<id>.json)。向后兼容旧 /chat 格式(无 id、用 conversation 键,
  读取时回退用文件名作 id)。标题自动取首条 user 消息前若干字。
- HistoryMixin 升级(cli_history.py):新增 init_session_manager /
  _autosave_session / handle_resume_command。自动保存首轮惰性建会话、
  更新标题与 updated_at;/resume 列出(序号|ID)或按序号|ID|前缀恢复,
  恢复时把 messages 载入 shared_conversation_history 并同步引擎。
- 集成:cli_base.__init__ 在 chat_history_dir 就绪后 init_session_manager;
  cli_core 主循环 1395 行 process_conversation 后调用 _autosave_session;
  新增 /resume|/sessions 路由(/sessions 等同 /resume)。
- 体验:启动时若有历史会话提示 /resume(含最近会话标题);run() 紧凑提示
  与 /help 速查表均补 /resume(列出/恢复/开新会话)。
- 修复 cli_history.py 一处历史 typo:{Style.RESET型} -> {Style.RESET_ALL}
  (旧 load_chat_history 在含消息时会抛 AttributeError)。
- 测试:新增 tests/test_session.py(12 项,覆盖兼容/新建/落盘/排序/解析/
  自动保存/恢复/列表/未知token,全程临时目录不污染真实 config/chat_history)。
  全量 242 passed(原 230)。

真实 CLI 冒烟:启动提示识别到历史会话、/resume 正确列出(含标题/时间/引擎/
条数)、/resume <id> 正确恢复并回显;已清理测试种入文件,chat_history 复原为空。
2026-08-05 17:16:49 +08:00

202 lines
7.2 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)持久化与恢复。
把 openai/ollama/manual 等引擎的对话历史(self.shared_conversation_history)
按「会话」维度持久化到 chat_history/<id>.json,支持:
- 自动落盘(每轮对话后调用 SessionManager.save)
- 列出历史会话(/resume、/sessions)
- 一键恢复(把 messages 重新载入 shared_conversation_history)
存储格式(新):
{
"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 键。
"""
import os
import re
import time
import json
import uuid
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):
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 []
@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 = ""):
# 兼容旧 /chat save 格式:无 id、用 "conversation" 键
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:
"""会话的读写与索引管理。"""
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
# ── 新建 ──
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 save(self, session: Session, messages, engine: str = "", model: str = ""):
"""把当前对话内容写回到 session 对应的文件。"""
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)
session.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
if not session.created_at:
session.created_at = session.updated_at
file_path = os.path.join(self.history_dir, f"{session.id}.json")
try:
with open(file_path, "w", encoding="utf-8") as f:
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2, default=str)
return True
except OSError:
return False
# ── 列出 ──
def list_sessions(self) -> list:
"""返回按更新时间倒序的 Session 列表。"""
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):
continue
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]
# 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:
if s.id == token:
return s
return None
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")
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