# 对话历史保留与压缩原理

## 一、历史存储结构

```
users/<name>/history/
├── chat/                   ← 聊天历史文件（chat_data.json，JSON 覆盖写入）
├── log/                    ← 对话日志（chat_log.json）+ 工具日志（tool_log.jsonl）+ 附件日志（external_attachments.jsonl）
├── archive/                ← 归档对话（history_*.json 或 .json.gz）
└── file/                   ← 用户文件池（上传、外部附件）
```

## 二、ChatManager 消息模型

```python
# run/chat.py
class ChatManager:
    def __init__(self, user_dir, core_config, user_config):
        self.messages: list[dict] = []        # 完整历史（内存）
        self.system_prompt: str = ""          # 当前 system prompt
        self.context_max = 900000             # 上下文窗口上限（token）
        self.context_safe_ratio = 0.85        # 安全比例
        self.max_history = hist_cfg["history_max"]  # 来自 core_config["history"]
        self._prompt_valid = False                 # prompt 缓存标记
        self._compress_occurred = False       # 本轮的压缩 flag（供 SSE 通知前端）
```

消息结构（每条）：
```json
{
  "role": "user" | "assistant" | "tool",
  "content": "消息文本",
  "tool_calls": [{"id": "call_xxx", "type": "function", "function": {"name": "...", "arguments": "..."}}],
  "tool_call_id": "call_xxx",
  "name": "tool_name",
  "reasoning_content": "思考内容（可选）"
}
```

## 三、两种压缩机制

当前实现有两层压缩，分别在两个维度上保护上下文窗口。

### 3.1 历史条数压缩：`_trim_if_needed()`

触发条件：`len(self.messages) >= self.max_history`

```python
def _trim_if_needed(self):
    if len(self.messages) < self.max_history:
        return
    self._compress_occurred = True  # 通知前端

    keep = self.max_history // 2
    # 向后扫描到安全切点（不切断 tool_calls/tool 配对）
    safe = cut
    for i in range(cut, len(self.messages)):
        if self.messages[i].get("role") != "tool":
            safe = i; break

    trimmed = self.messages[:safe]
    self.messages = self.messages[safe:]

    # 生成摘要插入历史开头
    digest = self._extract_digest(trimmed)
    if digest:
        self.messages.insert(0, {"role": "user", "content": "\n".join(digest + [...]} )

    # 触发 auto_improve 子代理提取临时记忆
    run_auto_improve(provider, trimmed, user_dir)
```

压缩流程：
1. 条数超限 → 裁剪前半段消息
2. 用安全切点保证不切断 tool_calls/tool 配对
3. 生成文本摘要插入历史开头
4. 同步触发 auto_improve 提取临时记忆
5. 设置 `_compress_occurred` → SSE 层发送 `ui_status` 通知前端

### 3.2 Token 预算压缩：`_ensure_token_budget()`

触发条件：`build_messages()` 组装的消息 token 总数超过 `context_max * safe_ratio`

```python
def build_messages(self):
    msgs = [{"role": "system", "content": self.system_prompt}] + self.messages
    return self._ensure_token_budget(msgs)

def _ensure_token_budget(self, messages):
    total = sum(self._msg_tokens(m) for m in messages)
    if total <= safe_budget:
        return messages  # 无需压缩

    self._compress_occurred = True  # 通知前端

    # system prompt 超大时截断
    # 从索引 1 向后找安全切点（不切断 tool_calls/tool 配对）
    # 循环裁剪直到 token 数低于 safe_budget
    # 裁剪后生成摘要插入 system 之后
```

压缩流程：
1. 计算所有消息的 token 总数
2. 超出安全阈值 → 循环裁剪历史前端
3. 保护 system prompt 和 tool_call/tool 配对
4. 生成摘要插入 system prompt 之后
5. 设置 `_compress_occurred`

## 四、Token 估算（无模型调用，纯本地计算）

当前**不使用** LLM tokenizer，而是基于字符统计的本地估算：

```python
@staticmethod
def _estimate_tokens(text: str) -> int:
    # CJK 字符: ~0.65 token/字
    # 其他多字节: ~1 token/字
    # ASCII: ~0.3 token/字
    # 使用保守略高系数
```

## 五、压缩时机与 Web 通知

### 触发点

| 触发点 | 压缩类型 | 说明 |
|--------|---------|------|
| `add_user_message()` | `_trim_if_needed` | 用户发送消息时，若历史条数超限 |
| `add_tool_results()` | `_trim_if_needed` | 工具结果返回时 |
| `build_messages()` | `_ensure_token_budget` | LLM 调用前组装消息时 |

### Web 顶部状态通知

压缩产生的前端可见行为：

1. **SSE 流开始前**：预估压缩可能性 → 若接近上限，`yield ui_status "正在压缩上下文..."` → 执行压缩 → `yield ui_status_clear`
2. **SSE 流进行中**：检测 `_compress_occurred` flag → `yield ui_status + ui_status_clear`
3. **前端 TopBar**：收到 `ui_status` 时在模型 badge 左侧显示临时状态 chip（spinner + 文案），`ui_status_clear` 后 500ms 渐隐

## 六、保存原理

```python
# Web: _run_chat_stream() finally
# 先 build_messages（可能触发压缩并改写 chat.messages），
# 再 save_history 确保压缩版持久化
full_messages = chat.build_messages()
chat.save_history()              # atomic_write_json → chat_data.json
chat.save_log(full_messages)     # atomic_write_json → chat_log.json
```

- `save_history()`：`atomic_write_json` 写入 `chat/chat_data.json`（JSON 覆盖写入）
- `save_log()`：`atomic_write_json` 写入 `log/chat_log.json`（JSON 覆盖写入）
- 顺序很重要：先 `build_messages()` 触发可能压缩并更新 `chat.messages`，再 `save_history()` 把压缩版持久化
- 保存发生在每轮对话结束后

## 七、外部消息历史

外部消息（QQ/Telegram）通过 `AgentService.chat()` 调用，历史与 Web/CLI 共享同一个 `ChatManager`：
- 同一用户的所有渠道共享历史
- source 前缀作为 user 消息的一部分保存
- 外部消息的附件路径也保存在消息中
- 外部消息的压缩机制与 Web 完全一致

## 八、关键配置

| 配置项 | 位置 | 说明 |
|--------|------|------|
| `context_window.max_tokens` | `config/config_core.json` | 上下文窗口上限（默认 900000） |
| `context_window.safe_ratio` | `config/config_core.json` | token 预算安全比例（默认 0.85） |
| `user_config.history` | `users/<name>/config.json` | 历史保存开关（data/log） |
| `ChatManager.max_history` | `config/config_core.json` → `history.history_max` | 最大历史条数 |
