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),无回归
This commit is contained in:
qing committed 2026-08-13 16:54:32 +08:00
1 parent 8cb822b511
commit 85133868f3
4 files changed
+782 -324

No files matched your search

+4
View File
@@ -7,6 +7,8 @@ subsystems - aibox 子系统模块(包)
- prompt_manager.py : PromptManager(单例)
- db.py : MemoryDatabase + MemoDatabase
- skill_manager.py : SkillManager
Wave 2:
- conversation_history.py : ConversationHistory(对话历史管理)
依赖方向:common <- subsystems <- aibox
不反向依赖 aibox 任何符号。
@@ -19,6 +21,7 @@ from .executors import CommandExecutor, SubprocessCommandExecutor
from .prompt_manager import PromptManager
from .db import MemoryDatabase, MemoDatabase
from .skill_manager import SkillManager
from .conversation_history import ConversationHistory
__all__ = [
"CommandExecutor",
@@ -27,4 +30,5 @@ __all__ = [
"MemoryDatabase",
"MemoDatabase",
"SkillManager",
"ConversationHistory",
]
+348
View File
@@ -0,0 +1,348 @@
#!/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