第五轮审计(J1~J16)全量修复 + 子代理 max_turns 钳制: - 子代理 max_turns 钳制(0/None 回退默认 10,负数钳制 1)+ 14 项回归测试 - J1 任务系统投毒:add_memo 入库校验(时间/间隔/类型)、调度器预检与触发事务 防御、展示路径兜底、register 移入 try、add_user_task 补 repeat_end_time 透传 - J2/J13 前端 XSS:三页面 marked 输出经 DOMPurify 净化、entry1 系统消息/任务 分隔条改 textContent、CDN 脚本补 SRI - J3 FTS 删除换序(先 fts 后内容表,索引不再残留) - J5 原子写推广:新增 common/atomic.py(mkstemp+fsync+os.replace),config/prompt/ history 全部接入;损坏配置备份后回退 - J6 单例初始化加锁(PromptManager/ConfigManager 并发首例不双重加载) - J7 broadcast 并发发送 + wait_for 超时回收僵死连接 - J4/J8/J9~J12/J14~J16:验证异常脱敏、交互输入容错、MemoDatabase WAL+busy_timeout、 memo 批量删除、/new 竞态、skills 引号转义、voice_client 清理、死代码移除、 skill_manager 同名键/help 截断
371 lines
15 KiB
Python
371 lines
15 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 os
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||
|
||
from common import ConfigManager, Logger, constants
|
||
from common.atomic import atomic_write_text
|
||
|
||
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 Exception as e:
|
||
# N16:收窄为 Exception——裸 except 连 KeyboardInterrupt/SystemExit
|
||
# 也吞掉;损坏时重置为空索引并告警
|
||
self.logger.warning(f"历史索引文件损坏,已重置: {e}")
|
||
return {"conversations": [], "last_id": 0}
|
||
return {"conversations": [], "last_id": 0}
|
||
|
||
@staticmethod
|
||
def _atomic_write_json(path: Path, data) -> None:
|
||
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
|
||
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
|
||
后仅静默置 None,历史内容丢失)。J5(2026-08-19):委托 common.atomic
|
||
(mkstemp 随机名),修复旧实现固定 .tmp 名在多进程并发写同路径互踩。"""
|
||
atomic_write_text(str(path), json.dumps(data, ensure_ascii=False, indent=2))
|
||
|
||
def _save_history_index(self):
|
||
"""保存历史索引"""
|
||
self._atomic_write_json(self.history_file, self.history_index)
|
||
|
||
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:
|
||
self._atomic_write_json(self.current_file, self.current_conversation)
|
||
|
||
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
|
||
)
|
||
|
||
# N16:归档文件名加序号防同秒覆盖——DATE_FORMAT 精度为秒,
|
||
# 同一秒内两次归档(如测试/快速重置)会静默覆盖前一份
|
||
timestamp = datetime.now().strftime(constants.DATE_FORMAT)
|
||
archive_filename = f"conversation_{timestamp}.json"
|
||
counter = 1
|
||
while (self.archive_dir / archive_filename).exists():
|
||
archive_filename = f"conversation_{timestamp}_{counter}.json"
|
||
counter += 1
|
||
archive_path = self.archive_dir / archive_filename
|
||
|
||
self._atomic_write_json(archive_path, self.current_conversation)
|
||
|
||
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
|
||
|
||
# I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦
|
||
# 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验
|
||
archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir)))
|
||
target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name)))
|
||
if not target.startswith(archive_root + os.sep):
|
||
return None
|
||
file_path = Path(target)
|
||
if file_path.exists():
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
return None
|