Files
blackbird/aibox/subsystems/conversation_history.py
T
qing 85133868f3 refactor(aibox): 抽离ConversationHistory至subsystems(Wave2阶段1)
- 新增 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),无回归
2026-08-13 16:54:32 +08:00

349 lines
13 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.
#!/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