- 新增 subsystems/conversation_history.py: 迁移对话历史管理器(318行),MemoryDatabase用TYPE_CHECKING注解避免循环导入 - subsystems/__init__.py: 导出ConversationHistory,更新docstring含Wave2 - aibox.py: 移除ConversationHistory类定义(3706->3388行),仅保留import与类型注解 - 新增 tests/test_conversation_history.py: 41项单测,覆盖构造/当前对话/归档/历史加载/memory_db集成 - 测试基线: 171->212项全过(12.13s),无回归
349 lines
13 KiB
Python
349 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
subsystems.conversation_history - 对话历史管理器
|
||
|
||
抽离自 aibox.py,管理当前对话与归档历史,提供上下文加载能力。
|
||
|
||
职责:
|
||
- 维护当前对话(current_conversation.json)
|
||
- 归档历史对话(archive/*.json)+ 历史索引(conversation_history.json)
|
||
- 加载历史消息作为 AI 上下文
|
||
- 归档/结束时可选写入 MemoryDatabase
|
||
|
||
依赖:common (constants, ConfigManager, Logger) + subsystems.db (MemoryDatabase) + 标准库
|
||
不反向依赖 aibox 任何符号。
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||
|
||
from common import ConfigManager, Logger, constants
|
||
|
||
if TYPE_CHECKING:
|
||
# 仅用于类型注解,避免运行时循环导入;MemoryDatabase 来自同包 subsystems.db
|
||
from subsystems.db import MemoryDatabase
|
||
|
||
|
||
class ConversationHistory:
|
||
"""对话历史管理器 - 管理所有历史对话"""
|
||
|
||
def __init__(self, save_dir: str = None, memory_db: "MemoryDatabase" = None, load_history: bool = None):
|
||
if save_dir is None:
|
||
save_dir = constants.SAVE_DIR
|
||
|
||
self.save_dir = Path(save_dir)
|
||
self.archive_dir = self.save_dir / constants.ARCHIVE_DIR
|
||
self.history_file = self.save_dir / constants.HISTORY_FILE
|
||
self.memory_db = memory_db
|
||
|
||
config = ConfigManager()
|
||
if load_history is None:
|
||
load_history = config.get("system.load_history_by_default", True)
|
||
self.load_history = load_history
|
||
|
||
# 确保目录存在
|
||
self.save_dir.mkdir(parents=True, exist_ok=True)
|
||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
self.logger = Logger("HistoryManager")
|
||
self.history_index = self._load_history_index()
|
||
self.current_conversation = None
|
||
self.current_file = self.save_dir / constants.CURRENT_CONVERSATION_FILE
|
||
|
||
self._load_current_conversation()
|
||
|
||
def _load_history_index(self) -> Dict:
|
||
"""加载历史索引文件"""
|
||
if self.history_file.exists():
|
||
try:
|
||
with open(self.history_file, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except:
|
||
return {"conversations": [], "last_id": 0}
|
||
return {"conversations": [], "last_id": 0}
|
||
|
||
def _save_history_index(self):
|
||
"""保存历史索引"""
|
||
with open(self.history_file, 'w', encoding='utf-8') as f:
|
||
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
|
||
|
||
def _load_current_conversation(self):
|
||
"""加载当前的对话(如果有)"""
|
||
if self.current_file.exists():
|
||
try:
|
||
with open(self.current_file, 'r', encoding='utf-8') as f:
|
||
self.current_conversation = json.load(f)
|
||
self.logger.info(f"已加载当前对话: {self.current_conversation.get('topic', '未命名')}")
|
||
except Exception as e:
|
||
self.logger.error(f"加载当前对话失败: {e}")
|
||
|
||
def save_current_conversation(self):
|
||
"""保存当前对话"""
|
||
if self.current_conversation:
|
||
with open(self.current_file, 'w', encoding='utf-8') as f:
|
||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
||
|
||
def start_new_conversation(self, topic: str = None) -> Dict:
|
||
"""开始新对话"""
|
||
if self.current_conversation:
|
||
self.archive_current_conversation()
|
||
|
||
conv_id = str(int(time.time()))
|
||
self.current_conversation = {
|
||
"id": conv_id,
|
||
"start_time": datetime.now().strftime(constants.DATETIME_FORMAT),
|
||
"end_time": None,
|
||
"topic": topic or "新对话",
|
||
"messages": [],
|
||
"tool_calls": [],
|
||
"stats": {
|
||
"user_messages": 0,
|
||
"ai_messages": 0,
|
||
"tool_calls": 0
|
||
}
|
||
}
|
||
|
||
self.save_current_conversation()
|
||
self.logger.info(f"开始新对话: {topic}")
|
||
return self.current_conversation
|
||
|
||
def archive_current_conversation(self, final_summary: str = None):
|
||
"""将当前对话归档到历史"""
|
||
if not self.current_conversation:
|
||
return None
|
||
|
||
if self.current_conversation.get("end_time") is None:
|
||
self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||
|
||
if final_summary and self.memory_db:
|
||
messages = self.current_conversation.get("messages", [])
|
||
content_preview = "\n".join([f"{m['speaker']}: {m['content'][:200]}"
|
||
for m in messages[-10:] if m.get('speaker')])
|
||
|
||
self.memory_db.add_memory(
|
||
topic=self.current_conversation["topic"],
|
||
summary=final_summary,
|
||
content=content_preview,
|
||
start_time=self.current_conversation["start_time"],
|
||
end_time=self.current_conversation["end_time"],
|
||
importance=2
|
||
)
|
||
|
||
timestamp = datetime.now().strftime(constants.DATE_FORMAT)
|
||
archive_filename = f"conversation_{timestamp}.json"
|
||
archive_path = self.archive_dir / archive_filename
|
||
|
||
with open(archive_path, 'w', encoding='utf-8') as f:
|
||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
||
|
||
self.history_index["conversations"].append({
|
||
"id": self.current_conversation["id"],
|
||
"file": archive_filename,
|
||
"start_time": self.current_conversation["start_time"],
|
||
"end_time": self.current_conversation["end_time"],
|
||
"topic": self.current_conversation["topic"],
|
||
"messages": len(self.current_conversation["messages"]),
|
||
"tool_calls": len(self.current_conversation["tool_calls"])
|
||
})
|
||
|
||
self._save_history_index()
|
||
self.logger.info(f"对话已归档: {archive_filename}")
|
||
|
||
self.current_conversation = None
|
||
if self.current_file.exists():
|
||
self.current_file.unlink()
|
||
|
||
return str(archive_path)
|
||
|
||
def load_all_history(self, max_messages: int = None) -> List[Dict]:
|
||
"""加载所有历史对话作为上下文"""
|
||
if max_messages is None:
|
||
max_messages = constants.MAX_HISTORY_CONTEXT
|
||
|
||
if not self.load_history:
|
||
return []
|
||
|
||
all_messages = []
|
||
|
||
if self.current_conversation:
|
||
current_msgs = self.current_conversation.get("messages", [])
|
||
for msg in current_msgs[-max_messages//2:]:
|
||
if not msg.get("is_history"):
|
||
all_messages.append({
|
||
"timestamp": msg["timestamp"],
|
||
"role": msg["role"],
|
||
"speaker": msg.get("speaker"),
|
||
"content": msg["content"],
|
||
"from_current": True
|
||
})
|
||
|
||
history_files = sorted(self.archive_dir.glob("*.json"), reverse=True)[:constants.MAX_HISTORY_CONVERSATIONS]
|
||
|
||
for hist_file in history_files:
|
||
try:
|
||
with open(hist_file, 'r', encoding='utf-8') as f:
|
||
conv = json.load(f)
|
||
messages = conv.get("messages", [])
|
||
for msg in messages[-max_messages//len(history_files):]:
|
||
if not msg.get("is_history"):
|
||
all_messages.append({
|
||
"timestamp": msg["timestamp"],
|
||
"role": msg["role"],
|
||
"speaker": msg.get("speaker"),
|
||
"content": msg["content"],
|
||
"from_file": hist_file.name
|
||
})
|
||
except Exception as e:
|
||
self.logger.error(f"加载历史文件 {hist_file} 失败: {e}")
|
||
|
||
all_messages.sort(key=lambda x: x["timestamp"])
|
||
|
||
if len(all_messages) > max_messages:
|
||
all_messages = all_messages[-max_messages:]
|
||
|
||
self.logger.info(f"已加载 {len(all_messages)} 条历史消息")
|
||
return all_messages
|
||
|
||
def get_conversation_context(self, max_messages: int = None) -> List[Dict]:
|
||
"""获取适合作为AI上下文的对话历史"""
|
||
if max_messages is None:
|
||
max_messages = constants.MAX_HISTORY_CONTEXT
|
||
|
||
if not self.load_history:
|
||
return []
|
||
|
||
history_messages = self.load_all_history(max_messages)
|
||
|
||
context = []
|
||
for msg in history_messages:
|
||
role = msg.get("role", "")
|
||
content = msg.get("content", "")
|
||
|
||
if role == "system":
|
||
continue
|
||
|
||
if role == "assistant":
|
||
speaker = msg.get("speaker", "")
|
||
context.append({
|
||
"role": "assistant",
|
||
"content": f"[{speaker}] {content}"
|
||
})
|
||
else:
|
||
context.append({
|
||
"role": "user",
|
||
"content": content
|
||
})
|
||
|
||
return context
|
||
|
||
def add_message(self, role: str, content: str, speaker: str = None):
|
||
"""添加消息到当前对话"""
|
||
if not self.current_conversation:
|
||
self.start_new_conversation()
|
||
|
||
message = {
|
||
"timestamp": datetime.now().strftime(constants.DATETIME_FORMAT),
|
||
"role": role,
|
||
"speaker": speaker,
|
||
"content": content
|
||
}
|
||
|
||
self.current_conversation["messages"].append(message)
|
||
|
||
if role == "user":
|
||
self.current_conversation["stats"]["user_messages"] += 1
|
||
elif role == "assistant":
|
||
self.current_conversation["stats"]["ai_messages"] += 1
|
||
|
||
self.save_current_conversation()
|
||
|
||
def add_tool_call(self, tool_name: str, args: Dict, result: str):
|
||
"""记录工具调用"""
|
||
if not self.current_conversation:
|
||
return
|
||
|
||
tool_call = {
|
||
"timestamp": datetime.now().strftime(constants.DATETIME_FORMAT),
|
||
"tool": tool_name,
|
||
"args": args,
|
||
"result_preview": result[:200] + "..." if len(result) > 200 else result
|
||
}
|
||
|
||
self.current_conversation["tool_calls"].append(tool_call)
|
||
self.current_conversation["stats"]["tool_calls"] += 1
|
||
self.save_current_conversation()
|
||
|
||
def end_current_conversation(self, summary: str = None):
|
||
"""结束当前对话"""
|
||
if self.current_conversation:
|
||
self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||
|
||
if summary and self.memory_db:
|
||
messages = self.current_conversation.get("messages", [])
|
||
content_preview = "\n".join([f"{m['speaker']}: {m['content'][:200]}"
|
||
for m in messages[-10:] if m.get('speaker')])
|
||
|
||
self.memory_db.add_memory(
|
||
topic=self.current_conversation["topic"],
|
||
summary=summary,
|
||
content=content_preview,
|
||
start_time=self.current_conversation["start_time"],
|
||
end_time=self.current_conversation["end_time"],
|
||
importance=2
|
||
)
|
||
|
||
self.save_current_conversation()
|
||
self.logger.info("当前对话已保存")
|
||
|
||
def reset_conversation(self):
|
||
"""重置当前对话状态"""
|
||
if self.current_conversation:
|
||
self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||
self.save_current_conversation()
|
||
self.current_conversation = None
|
||
|
||
def list_conversations(self) -> List[Dict]:
|
||
"""列出所有对话(包括当前和归档)"""
|
||
conversations = []
|
||
|
||
if self.current_conversation:
|
||
conversations.append({
|
||
"file": constants.CURRENT_CONVERSATION_FILE,
|
||
"is_current": True,
|
||
"id": self.current_conversation.get("id"),
|
||
"start_time": self.current_conversation.get("start_time"),
|
||
"end_time": self.current_conversation.get("end_time"),
|
||
"topic": self.current_conversation.get("topic"),
|
||
"messages": len(self.current_conversation.get("messages", [])),
|
||
"tool_calls": len(self.current_conversation.get("tool_calls", []))
|
||
})
|
||
|
||
for conv in self.history_index.get("conversations", []):
|
||
conversations.append({
|
||
"file": conv["file"],
|
||
"is_current": False,
|
||
"id": conv["id"],
|
||
"start_time": conv["start_time"],
|
||
"end_time": conv["end_time"],
|
||
"topic": conv["topic"],
|
||
"messages": conv["messages"],
|
||
"tool_calls": conv["tool_calls"]
|
||
})
|
||
|
||
conversations.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True)
|
||
return conversations
|
||
|
||
def load_conversation(self, file_name: str) -> Optional[Dict]:
|
||
"""加载指定的对话"""
|
||
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
|
||
return self.current_conversation
|
||
|
||
file_path = self.archive_dir / file_name
|
||
if file_path.exists():
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
return None
|