Files
xiaoli-cli/xcli_core/cli_history.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

121 lines
5.8 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.
import os
import time
import json
from colorama import Fore, Style
from .session import SessionManager
class HistoryMixin:
"""聊天记录管理相关方法(含 opencode 式会话持久化 /resume)"""
def save_chat_history(self, name):
"""保存当前聊天记录到文件"""
try:
chat_data = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"conversation": self.shared_conversation_history.copy()
}
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(chat_data, f, ensure_ascii=False, indent=2, default=str)
print(f"{Fore.GREEN}聊天记录已保存为: {name}{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}保存聊天记录失败: {e}{Style.RESET_ALL}")
return False
def list_chat_history(self):
"""列出所有已保存的聊天记录"""
try:
chat_files = [f for f in os.listdir(self.chat_history_dir) if f.endswith('.json')]
if not chat_files:
print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}")
for i, file in enumerate(chat_files, 1):
file_path = os.path.join(self.chat_history_dir, file)
mod_time = os.path.getmtime(file_path)
mod_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mod_time))
chat_name = file[:-5]
print(f"{Fore.GREEN}{i}. {chat_name} (保存时间: {mod_time_str}){Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}列出聊天记录失败: {e}{Style.RESET_ALL}")
def load_chat_history(self, name):
"""加载指定的聊天记录"""
try:
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
if not os.path.exists(file_path):
print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}")
return False
with open(file_path, 'r', encoding='utf-8') as f:
chat_data = json.load(f)
self.shared_conversation_history = chat_data.get("conversation", [])
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}")
if self.shared_conversation_history:
print(f"{Fore.CYAN}加载了 {len(self.shared_conversation_history)} 条对话记录{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
return False
# ── opencode 式会话(自动持久化 + /resume) ──
def init_session_manager(self):
"""初始化会话管理器与当前会话状态。在 chat_history_dir 就绪后调用。"""
self.session_manager = SessionManager(self.chat_history_dir)
self.current_session = None # 首轮对话时惰性创建
def _autosave_session(self):
"""每轮对话后调用:把当前会话落盘(首轮自动建会话)。"""
if not getattr(self, 'shared_conversation_history', None):
return
if not hasattr(self, 'session_manager'):
return
engine = getattr(self.current_engine, 'name', None) if self.current_engine else None
model = getattr(self, 'current_model', None)
try:
if self.current_session is None:
self.current_session = self.session_manager.new_session(engine=engine, model=model)
self.session_manager.save(
self.current_session, self.shared_conversation_history,
engine=engine, model=model,
)
except Exception:
pass # 自动保存失败不应中断对话
def handle_resume_command(self, args):
"""/resume 命令:列出或恢复历史会话。"""
args = (args or "").strip()
if not hasattr(self, 'session_manager'):
self.init_session_manager()
if args == 'new':
self.current_session = None
print(f"{Fore.GREEN}已开始新会话(下一轮对话自动创建){Style.RESET_ALL}")
return
sessions = self.session_manager.list_sessions()
if not sessions:
print(f"{Fore.YELLOW}没有可恢复的会话{Style.RESET_ALL}")
return
if not args:
print(f"{Fore.GREEN}已保存的会话 (按时间倒序):{Style.RESET_ALL}")
for i, s in enumerate(sessions, 1):
line = f"{Fore.GREEN}{i}. {s.id}{Style.RESET_ALL} {s.title}"
meta = f" {s.updated_at} {s.engine or '-'}/{s.model or '-'} {s.msg_count} 条"
print(line + f"{Fore.CYAN}{meta}{Style.RESET_ALL}")
print(f"{Fore.CYAN}用法: /resume <序号或ID> 恢复 · /resume new 开新会话{Style.RESET_ALL}")
return
s = self.session_manager.load(args)
if s is None:
print(f"{Fore.RED}未找到会话: {args}{Style.RESET_ALL}")
return
self.shared_conversation_history = list(s.messages)
self.current_session = s
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已恢复会话: {s.id}{Style.RESET_ALL}")
print(f"{Fore.CYAN}标题: {s.title} · {s.msg_count} 条记录 · 引擎 {s.engine or '-'}/{s.model or '-'}{Style.RESET_ALL}")
cur = getattr(self.current_engine, 'name', None) if self.current_engine else None
if s.engine and cur and cur != s.engine:
print(f"{Fore.YELLOW}提示: 当前引擎为 {cur},可用 /engine switch {s.engine} 切回该会话的引擎{Style.RESET_ALL}")