535 lines
23 KiB
Markdown
535 lines
23 KiB
Markdown
# 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 |
|