# AI Chat 上下文管理增强设计

> 性质: 功能设计（经代码勘察确认可行性 + 多角度分析）
> 日期: 2026-06-16
> 关联: [Agent 架构说明](Agent架构说明-2026-06-14.md)（现有裁剪机制盘点依据）
> 用途: 实施依据，实施方据此文档执行，不再二次设计

---

## 1. 背景与问题

### 1.1 现有裁剪机制的致命缺陷

当前 `ContextManager.build_for_request`（context.rs:219）超预算时的行为：

```
history_tokens > available_budget
  ↓
从最旧消息开始丢弃（按三元组原子单元），保护最近 6 条（PROTECT_COUNT）
  ↓
被丢弃的消息 → 彻底不发给 LLM，零保留
  ↓
trimmed=true → agentic.rs:226 `_trimmed` → 直接忽略，无任何动作
```

**问题**：丢掉的消息里可能有「用户说要改 XX 文件的 YY 函数」「AI 已经用了方案 B 不是方案 A」这类关键上下文。丢弃 = 遗忘 = AI 重复读文件 / 改错地方。

### 1.2 现有清理机制的缺陷

`ai_chat_clear`（commands.rs:360）：`session.messages.clear()` + DB `clear_messages` 置 `messages='[]'` → **历史彻底丢失**，无法回溯。

### 1.3 用户需求（三条）

1. **会话分段**：清理上下文不删记录，同一对话内产生 a'/a''/a''' 多段，DB 全量保留，LLM 只看当前段
2. **手动压缩**：用户主动发起，把当前消息让 LLM 总结成摘要，摘要替代原始消息作为上下文起点
3. **智能裁剪**：超预算自动触发压缩，不直接丢弃，用摘要保留旧消息语义

---

## 2. 代码勘察（事实依据）

### 2.1 数据流真相链

```
DB (ai_conversations.messages JSON 列)
  ↕ save_conversation / switchConversation → restore_from_messages
ContextManager (内存真相源, Vec<TrackedMessage>)
  ↓ build_for_request(sys_tokens)
  ↓   → sanitize_messages (step 0: filter !is_active + step 1-3: 畸形三元组自愈)
  ↓   → 超预算时裁剪旧消息(保护最近 PROTECT_COUNT=6 条)
LLM 请求 = [system_prompt] + [裁剪后的 active 消息]
```

### 2.2 关键代码位置

| 机制 | 位置 | 行为 |
|------|------|------|
| `ChatMessage.status` | `df-ai-core/provider.rs:59` | `Option<String>`，现有值：`None`/`"active"`/`"truncated"` |
| `is_active()` | `df-ai-core/provider.rs:79` | `!matches!(status, Some("truncated"))` — **反面排除模式** |
| `sanitize_messages` | `context.rs:284` | step 0 调 `is_active()` 过滤，**唯一的发送视图过滤点** |
| `build_for_request` | `context.rs:219` | 先 sanitize 再裁剪，返回 `(messages, trimmed: bool)` |
| `push()` | `context.rs:174` | 追加消息 + 计 token，**不区分 status 全量计入 history_tokens** |
| `clear()` | `context.rs:170` | 清空 messages Vec + history_tokens=0 |
| `restore_from_messages` | `context.rs:414` | 从 DB JSON 全量恢复，**push 全量消息含 !active 的，token 全量计入** |
| `ai_chat_clear` | `commands.rs:360` | session.clear() + DB clear_messages 置 `messages='[]'` |
| `build_for_request` 调用点 | `agentic.rs:226` | `let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens)` — **trimmed 被忽略** |
| 前端过滤 | `useAiConversations.ts:79` | `.filter(m => m.status !== 'truncated')` — **硬编码字符串** |
| `title.rs` LLM 调用 | `title.rs:111-140` | `generate_title_via_llm` — **完整的非流式 complete() 调用范例**，压缩可直接复用此模式 |
| `build_provider_for` | `secret.rs` | 构造 provider 句柄（含 keyring 解析），title.rs 和压缩共用 |

### 2.3 ContextConfig 预算参数

```rust
// context.rs:88-94
pub struct ContextConfig {
    max_tokens: u32,        // 128_000
    output_reserve: u32,    // 8_192
    safety_ratio: f32,      // 0.85
}
// budget_limit() = (128_000 - 8_192) × 0.85 ≈ 102_000 tokens
```

### 2.4 消息分组（裁剪原子性）

```rust
// context.rs:115-123
enum MessageGroup {
    Standalone,       // 普通 User/Assistant 文本
    ToolCallHead,     // Assistant 带 tool_calls（三元组的头）
    ToolResultTail,   // Tool 结果消息（三元组的尾）
}
```

`build_eviction_units`（context.rs:526）已实现按三元组分组：Head + 所有 Tail + 紧随的文本 Assistant 作为不可分割的淘汰单元。**分段标记和压缩可复用此分组逻辑保证原子性**。

---

## 3. 统一设计

### 3.1 ChatMessage.status 值域（统一）

```
None / "active"           → 正常消息，进 LLM 上下文 + 前端可见
"truncated"               → UX-09 编辑软删，不进上下文 + 前端隐藏
"archived_segment"        → 会话分段标记，不进上下文 + 前端折叠可见
"compressed"              → 被压缩替代，不进上下文 + 前端折叠可见
```

### 3.2 is_active() 改为正面白名单

```rust
// 改前（反面排除，每加一个新状态都要改）
pub fn is_active(&self) -> bool {
    !matches!(self.status.as_deref(), Some("truncated"))
}

// 改后（正面白名单，新状态自动不 active）
pub fn is_active(&self) -> bool {
    matches!(self.status.as_deref(), None | Some("active"))
}
```

**影响面**：`sanitize_messages` step 0 调 `is_active()` 过滤，改后 `archived_segment` 和 `compressed` 自动被过滤，**零额外改动**。

### 3.3 push() / restore_from_messages() 的 token 计算修正

**问题**：当前 `push()` 不区分 status 全量计入 `history_tokens`。`restore_from_messages` 从 DB 恢复时 push 全量消息（含 compressed/archived_segment），导致 token 虚高 → `build_for_request` 误判超预算 → 不必要的裁剪。

**修正**：

```rust
// push() 加 status 守卫
pub fn push(&mut self, message: ChatMessage) {
    let tokens = self.estimator.estimate_message(&message);
    let group = classify_group(&message);
    // 仅 active 消息计入 token 预算（compressed/archived_segment 不进 LLM 上下文）
    if message.is_active() {
        self.history_tokens += tokens;
    }
    self.messages.push(TrackedMessage { message, token_count: tokens, group });
}
```

**注意**：`all_messages_clone()` 仍返回全量（含 !active），持久化不受影响。`build_for_request` 先 sanitize 过滤再算预算，行为一致。

---

## 4. 三大功能设计

### 4.1 会话分段（清理上下文不删记录）

**语义**：用户点「清理上下文」→ 当前所有 active 消息标记 `archived_segment` → 后续新消息作为新段继续同一对话。

**IPC**：`ai_chat_clear_context`

**后端逻辑**：

```rust
pub async fn ai_chat_clear_context(state: State<'_, AppState>) -> Result<(), String> {
    let conv_id = {
        let mut session = state.ai_session.lock().await;
        let id = session.active_conversation_id.clone();
        // 标记当前所有 active 消息为 archived_segment
        for tm in session.messages.messages_mut() {
            if tm.message.is_active() {
                tm.message.status = Some("archived_segment".to_string());
                // token 从预算中扣除
                session.messages.history_tokens = 
                    session.messages.history_tokens.saturating_sub(tm.token_count);
            }
        }
        id
    };
    // 落库（全量含标记）
    if let Some(id) = conv_id {
        save_conversation(&state.ai_session, &state.db, &id, None, None).await;
    }
    Ok(())
}
```

**不插分隔线 system 消息**——前端按 status 渲染分隔即可，避免无意义的 system 消息污染 DB。

**前端渲染**：`archived_segment` 消息折叠为灰色分隔条，点击展开。

```
┌─────────────────────────────────────────┐
│ ▸ --- 上下文已清理 (12 条消息) ---       │  ← 折叠态
├─────────────────────────────────────────┤
│ [当前段消息正常展示]                      │
└─────────────────────────────────────────┘
```

**关键约束**：标记时按三元组原子标记（一个 assistant(tool_call) + 其所有 tool_result 必须在同一段）。复用 `build_eviction_units` 的分组逻辑。

### 4.2 手动上下文压缩（LLM 摘要）

**语义**：用户点「压缩上下文」→ 当前段所有 active 消息发给 LLM 生成摘要 → 摘要作为 system 消息替代原始消息。

**IPC**：`ai_chat_compress_context`

**后端逻辑**：

```rust
pub async fn ai_chat_compress_context(state: State<'_, AppState>) -> Result<String, String> {
    // ① 取当前 active 消息 + provider 配置
    let (active_msgs, provider_config, conv_id) = { /* lock session, extract */ };
    
    // ② 构建 summary prompt（复用 title.rs 的 generate_title_via_llm 模式）
    let provider = build_provider_for(&provider_config)?;
    let summary = compress_via_llm(&*provider, &provider_config.default_model, active_msgs).await?;
    
    // ③ 原始消息标记 compressed + 插入摘要 system
    {
        let mut session = state.ai_session.lock().await;
        for tm in session.messages.messages_mut() {
            if tm.message.is_active() {
                tm.message.status = Some("compressed".to_string());
            }
        }
        // 插入摘要作为新的上下文起点
        session.messages.push(ChatMessage::system(&format!("## 上下文摘要\n\n{}", summary)));
    }
    
    // ④ 落库
    save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
    
    Ok(summary)
}
```

**摘要 prompt**（放 `prompt.rs`）：

```rust
pub fn compress_prompt(lang: &str) -> &'static str {
    match lang {
        "en" => "Summarize the following conversation context. Preserve:\n\
                 1) User's core intent and requirements\n\
                 2) Key decisions made (which approach, which files)\n\
                 3) Files modified/created with what changes\n\
                 4) Important constraints or preferences\n\
                 Be concise. Output structured summary only.",
        _ => "请将以下对话总结为关键上下文摘要，保留：\n\
              1) 用户的核心意图和需求\n\
              2) 已做的关键决策（用了什么方案、改了哪些文件）\n\
              3) 已修改/创建的文件及变更内容\n\
              4) 重要约束或偏好\n\
              简洁输出结构化摘要，不要输出其他内容。",
    }
}
```

**前端**：压缩按钮 + loading 态（`AiCompressing` / `AiCompressed` 事件）+ 压缩后摘要卡片（可展开看原始消息）。

**关键约束**：
- 压缩是单向的（不能"解压缩"恢复 LLM 上下文，但 DB 原始消息保留可查看）
- 压缩后 `history_tokens` 重算（仅含摘要 + 后续新消息）

### 4.3 智能裁剪（自动压缩）

**语义**：agentic loop 每轮 `build_for_request` 前检测，超预算时自动压缩被淘汰的旧消息，而非直接丢弃。

**核心约束**：压缩不能在 `build_for_request` 内同步做（它是纯函数 + 持 session 锁 + LLM 调用 1-5 秒）。

**落点**：agentic loop 循环体顶部，`build_for_request` 之前。

```rust
// agentic.rs run_agentic_loop 循环体内
for iteration in start_iteration..max_iterations {
    // ... stop_flag 检查 + 对话一致性校验 ...

    // ★ 智能压缩检查（build_for_request 之前）
    {
        let session = session_arc.lock().await;
        let budget = session.messages.config().budget_limit();
        let available = budget.saturating_sub(sys_tokens);
        if session.messages.history_tokens() > available 
           && session.messages.has_compressible_messages(PROTECT_COUNT + 4) 
           && !session.messages.is_compressing()  // 防重入
        {
            // 标记 compressing 防重入
            session.messages.set_compressing(true);
            drop(session);
            
            // emit 前端「正在压缩…」
            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressing { 
                conversation_id: conv_id.clone() 
            });
            
            // 取被淘汰消息 → LLM 摘要 → 标记 compressed → 插入摘要
            compress_old_messages(
                &*provider, &provider_config.default_model, 
                &session_arc, sys_tokens, &conv_id, &llm_concurrency,
            ).await;
            
            let mut session = session_arc.lock().await;
            session.messages.set_compressing(false);
            drop(session);
            
            // emit 前端「压缩完成」
            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressed { 
                conversation_id: conv_id.clone() 
            });
        }
    }

    // 正常 build_for_request（此时已压缩，不超预算或缓解）
    let messages = { /* build_for_request as before */ };
    // ... stream_llm ...
}
```

**`compress_old_messages` 核心逻辑**（抽为公共函数，手动/自动共用）：

```rust
async fn compress_old_messages(
    provider: &dyn LlmProvider,
    model: &str,
    session_arc: &Arc<Mutex<AiSession>>,
    sys_tokens: u32,
    conv_id: &str,
    llm_concurrency: &LlmConcurrency,
) {
    // ① 计算淘汰边界（保护区 + 余量）
    let compress_end = {
        let session = session_arc.lock().await;
        let protect = session.messages.len().saturating_sub(PROTECT_COUNT + 4);
        protect
    };
    if compress_end == 0 { return; }

    // ② 取被淘汰的 active 消息
    let (to_compress, lang) = {
        let session = session_arc.lock().await;
        let msgs: Vec<ChatMessage> = session.messages.iter()
            .take(compress_end)
            .filter(|m| m.is_active())
            .map(|m| m.message.clone())
            .collect();
        (msgs, session.agent_language.as_deref().unwrap_or("zh"))
    };
    if to_compress.is_empty() { return; }

    // ③ LLM 摘要（复用 compress_prompt + complete()，对齐 title.rs 模式）
    let summary = compress_via_llm(provider, model, to_compress, lang, llm_concurrency).await;
    
    match summary {
        Some(s) => {
            // ④ 标记原始消息 compressed + 插入摘要 system
            let mut session = session_arc.lock().await;
            for i in 0..compress_end {
                if session.messages[i].message.is_active() {
                    session.messages[i].message.status = Some("compressed".to_string());
                    session.messages.history_tokens = 
                        session.messages.history_tokens
                            .saturating_sub(session.messages[i].token_count);
                }
            }
            // 插入摘要在压缩点
            session.messages.insert_at(compress_end, ChatMessage::system(
                &format!("## 上下文摘要\n\n{}", s)
            ));
            // 落库
            drop(session);
            save_conversation(session_arc, &db, conv_id, None, None).await;
        }
        None => {
            // LLM 摘要失败 → 降级为原有裁剪行为（不阻塞 loop）
            tracing::warn!("自动压缩失败，降级为原有裁剪");
        }
    }
}
```

**阈值参数**：

```
COMPRESSION_TRIGGER: history_tokens > budget_limit × 0.6 时触发
COMPRESS_KEEP_RECENT: PROTECT_COUNT(6) + 4 = 10 条（保护区外再留余量）
```

**幂等**：`has_compressible_messages()` 检查保护区外是否存在 `status=None/active` 的消息。已标 `compressed` 的不参与二次压缩。`is_compressing()` 标志防重入。

**降级**：LLM 摘要失败时，`build_for_request` 仍走原有裁剪逻辑（丢弃旧消息），不阻塞 loop。

---

## 5. 数据流（修订后）

```
ContextManager (内存)
  ├── messages[0..k]: status="compressed" (已压缩，不进 build_for_request)
  ├── messages[k]: system "## 上下文摘要\n..." (压缩摘要，active)
  ├── messages[k+1..k+1+m]: status="archived_segment" (分段标记，不进)
  ├── messages[k+1+m..]: status=None (当前活跃段)
  
build_for_request:
  → sanitize_messages
    → step 0: filter is_active() → 剩 [摘要system] + [当前活跃消息]
    → step 1-3: 畸形三元组自愈
  → 超预算时仍裁剪（兜底，正常情况压缩后不超）
```

---

## 6. 三个功能的关系

```
用户场景时间线：
┌──────────────────────────────────────────────────┐
│ 段 a': 初始对话(改了5个文件)                      │
│   ↓ 用户点「清理上下文」                           │
│ 段 a'': 新话题(基于之前的修改继续)                  │
│   ↓ 对话变长，自动触发智能压缩                      │
│ 段 a''(压缩态): 旧消息被摘要替代                    │
│   ↓ 继续对话                                      │
│ 段 a''(续): 新消息追加在摘要后                      │
│   ↓ 用户主动点「压缩上下文」                        │
│ 段 a''(再压缩): 全部 active 消息被摘要替代           │
└──────────────────────────────────────────────────┘
```

| 维度 | 会话分段 | 手动压缩 | 智能裁剪 |
|------|------|------|------|
| 触发 | 用户点「清理上下文」 | 用户点「压缩上下文」 | `build_for_request` 前自动检测 |
| 范围 | 当前段全部 active 消息 | 当前段全部 active 消息 | 仅被淘汰的旧消息（保护区外） |
| 摘要 | 不生成摘要 | 全量摘要 | 部分摘要（仅淘汰部分） |
| status | `archived_segment` | `compressed` | `compressed` |
| 语义 | 开始新话题，旧的不可见 | 压缩全部，摘要替代 | 自动节约 token |

三者共享底层机制：`is_active()` 过滤 + status 标记 + `compress_via_llm` 公共函数。

---

## 7. 实施清单

### 7.1 基础层（其他都依赖）

| # | 改动 | 文件 | 说明 |
|---|------|------|------|
| B1 | `is_active()` 改白名单 | `df-ai-core/provider.rs:79` | `matches!(status, None \| Some("active"))` |
| B2 | `push()` 不计 !active 消息 token | `df-ai/context.rs:174` | `if message.is_active() { history_tokens += tokens }` |
| B3 | 压缩 prompt 模板 | `commands/ai/prompt.rs` | `compress_prompt(lang)` 函数 |
| B4 | `compress_via_llm` 公共函数 | `commands/ai/` 新文件或 title.rs 扩展 | 复用 title.rs 的 complete() 模式 |
| B5 | ContextManager 辅助方法 | `df-ai/context.rs` | `has_compressible_messages()` / `messages_mut()` / `history_tokens()` / `insert_at()` |

### 7.2 功能层

| # | 改动 | 文件 | 依赖 |
|---|------|------|------|
| F1 | IPC `ai_chat_clear_context`（会话分段） | `commands.rs` + `lib.rs` | B1 |
| F2 | IPC `ai_chat_compress_context`（手动压缩） | `commands.rs` + `lib.rs` | B1-B4 |
| F3 | agentic loop 自动压缩 | `agentic.rs` | B1-B5 + F2 的 `compress_old_messages` |
| F4 | `AiCompressing` / `AiCompressed` 事件 | `mod.rs` | F3 |

### 7.3 前端层

| # | 改动 | 文件 | 说明 |
|---|------|------|------|
| U1 | `clearContext()` / `compressContext()` API | `api/ai.ts` | 两个新 invoke |
| U2 | `clearContext()` / `compressContext()` 方法 | `useAiPanel.ts` | 调 API + 更新 state |
| U3 | 清理上下文按钮 + 压缩按钮 | `AiChat.vue` | 现有垃圾桶旁加两个按钮 |
| U4 | 分段折叠渲染 | `AiChat.vue` + `useAiConversations.ts:79` | archived_segment 过滤+折叠分隔条 |
| U5 | 压缩摘要卡片 | `AiChat.vue` | compressed 消息折叠为摘要卡片 |
| U6 | 自动压缩 loading 提示 | `useAiEvents.ts` | AiCompressing/AiCompressed 事件处理 |
| U7 | i18n | `aiChat.ts` zh/en | clearContext/compressContext/compressing/compressed 等 key |

### 7.4 实施顺序

```
阶段1（基础）: B1 → B2 → B3 → B4 → B5
阶段2（手动功能）: F1 + F2 → U1-U5 + U7  （用户可立即用）
阶段3（自动）: F3 + F4 → U6  （智能裁剪）
```

阶段 1-2 完成后用户已有「会话分段 + 手动压缩」完整能力。阶段 3 是锦上添花（自动触发）。

---

## 8. 风险与约束

### 8.1 is_active() 改白名单的兼容性

改前 `!matches!(Some("truncated"))` = 除了 truncated 都 active。
改后 `matches!(None | Some("active"))` = 只有 None/active 才 active。

**兼容性**：现有 status 值只有 `None`/`"active"`/`"truncated"` 三种。改后 `None` 和 `"active"` 仍 active，`"truncated"` 仍不 active。**零行为变化**。新增的 `archived_segment`/`compressed` 自动不 active（正是期望行为）。

### 8.2 工具调用三元组跨段/跨压缩

分段标记和压缩时，一个 assistant(tool_call) + 其 tool_result 必须在同一组。

**保障**：复用 `build_eviction_units`（context.rs:526）已实现的三元组分组逻辑。标记时按组原子操作。

### 8.3 压缩后 token 计数

压缩后 ContextManager 的 `history_tokens` 只含摘要 system + 后续 active 消息。`push()` 修正后（B2），`restore_from_messages` 从 DB 恢复时 compressed 消息不计入 token。

### 8.4 智能压缩的延迟

LLM `complete()` 调用 1-5 秒，在 agentic loop 内同步等待。用户感知为「正在压缩上下文…」loading。

**缓解**：仅在超预算 60% 时触发（不是每轮），典型对话全程不触发。

### 8.5 摘要质量

LLM 可能遗漏关键信息。`compress_prompt` 四段式结构化（意图/决策/文件/约束）最大化保留关键信息。摘要质量直接影响后续对话质量，prompt 模板放 `prompt.rs` 可迭代调优。

### 8.6 前端渲染复杂度

现有消息列表是 `v-for="msg in store.state.messages"` 线性渲染，无分组概念。

**最小方案**：archived_segment / compressed 消息各自折叠为分隔条/卡片，不引入复杂分组逻辑。`useAiConversations.ts:79` 过滤条件从 `status !== 'truncated'` 扩展为 `!status || status === 'active'`（与后端 is_active 白名单对齐），archived_segment/compressed/truncated 全部从线性列表过滤掉，由专门的折叠组件渲染。

---

## 9. 涉及文件汇总

| 文件 | 改动类型 |
|------|----------|
| `crates/df-ai-core/src/provider.rs` | B1: is_active() 改白名单 |
| `crates/df-ai/src/context.rs` | B2: push() token 修正 + B5: 辅助方法 |
| `src-tauri/src/commands/ai/prompt.rs` | B3: compress_prompt 模板 |
| `src-tauri/src/commands/ai/title.rs` 或新文件 | B4: compress_via_llm 公共函数 |
| `src-tauri/src/commands/ai/commands.rs` | F1+F2: 两个新 IPC |
| `src-tauri/src/commands/ai/agentic.rs` | F3: loop 顶部自动压缩检查 |
| `src-tauri/src/commands/ai/mod.rs` | F4: AiCompressing/AiCompressed 事件 |
| `src-tauri/src/lib.rs` | 注册新 IPC |
| `src/api/ai.ts` | U1: 两个新 invoke |
| `src/composables/ai/useAiPanel.ts` | U2: clearContext/compressContext |
| `src/composables/ai/useAiConversations.ts` | U4: 过滤条件扩展 |
| `src/composables/ai/useAiEvents.ts` | U6: 压缩事件处理 |
| `src/components/AiChat.vue` | U3+U4+U5: 按钮 + 分段折叠 + 摘要卡片 |
| `src/i18n/{zh-CN,en}/aiChat.ts` | U7: i18n key |
