新增: F-15上下文管理增强设计+波1审查登记(CR-45)
This commit is contained in:
534
docs/02-架构设计/F-15-上下文管理增强设计-2026-06-16.md
Normal file
534
docs/02-架构设计/F-15-上下文管理增强设计-2026-06-16.md
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
# 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 |
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
- [ ] UX-260616-05 [P1] — **打断按钮只停当前回复 + 队列保留续发(不清队列)**。用户需求:发送队列有消息时,打断按钮应只打断当前回复,并把队列中的多条消息一并发送给后续对话。现状:`stopChat`(useAiSend.ts:365-370) L368 `state.queue = []` **清空队列** → 用户生成中排队的消息随打断被**静默丢弃**。技术底座已就绪(已核后端):`stopChat`→`aiApi.stopChat`→后端 `stop_flag.load`(agentic.rs:178)→收尾 `save_conversation`(:185 已生成文本入库)+emit **`AiCompleted`**(agentic.rs:190,注释明确「保证前端收事件时后端已可接下一条,发送队列续发不被拒」)→前端 `ai-drain-queue`→`drainQueue`(useAiSend.ts:228) shift 队首续发。**最小修复(确定)**:删 stopChat L368 `state.queue=[]`,打断后 AiCompleted 自然触发 drainQueue 续发队首;当前未完成回复已入库(:185)保留为截断回复,无需额外处理。**决策点(待定)——「队列多条一并发送」语义**:**(a) 逐条续发**(删清队列即可,零改 drainQueue,AiCompleted 链式触发每条,保各条上下文独立)vs **(b) 合并成一条消息**(队列多条 text 拼接送,需改 drainQueue 为 bulkDrain 或合并 text 调一次 sendMessage)。**倾向 (a)**(逐条保上下文独立+零改链路;"一并"理解为「都送出不丢弃」非字面合并),**待用户确认**。— src/composables/ai/useAiSend.ts(stopChat:365 + drainQueue:228) + 后端 agentic.rs(:178/:190 stop 收尾 emit AiCompleted 已就绪)。
|
- [ ] UX-260616-05 [P1] — **打断按钮只停当前回复 + 队列保留续发(不清队列)**。用户需求:发送队列有消息时,打断按钮应只打断当前回复,并把队列中的多条消息一并发送给后续对话。现状:`stopChat`(useAiSend.ts:365-370) L368 `state.queue = []` **清空队列** → 用户生成中排队的消息随打断被**静默丢弃**。技术底座已就绪(已核后端):`stopChat`→`aiApi.stopChat`→后端 `stop_flag.load`(agentic.rs:178)→收尾 `save_conversation`(:185 已生成文本入库)+emit **`AiCompleted`**(agentic.rs:190,注释明确「保证前端收事件时后端已可接下一条,发送队列续发不被拒」)→前端 `ai-drain-queue`→`drainQueue`(useAiSend.ts:228) shift 队首续发。**最小修复(确定)**:删 stopChat L368 `state.queue=[]`,打断后 AiCompleted 自然触发 drainQueue 续发队首;当前未完成回复已入库(:185)保留为截断回复,无需额外处理。**决策点(待定)——「队列多条一并发送」语义**:**(a) 逐条续发**(删清队列即可,零改 drainQueue,AiCompleted 链式触发每条,保各条上下文独立)vs **(b) 合并成一条消息**(队列多条 text 拼接送,需改 drainQueue 为 bulkDrain 或合并 text 调一次 sendMessage)。**倾向 (a)**(逐条保上下文独立+零改链路;"一并"理解为「都送出不丢弃」非字面合并),**待用户确认**。— src/composables/ai/useAiSend.ts(stopChat:365 + drainQueue:228) + 后端 agentic.rs(:178/:190 stop 收尾 emit AiCompleted 已就绪)。
|
||||||
- [ ] UX-260616-06 [P2] — **队列消息支持编辑**。用户需求:队列中的消息支持编辑。现状:队列 UI(AiChat.vue:511-516) 每项只有 × 删除(cancelQueued:353),无编辑入口;队列项结构 `{ text, skill?, enqueuedAt }`(stores/ai.ts:70)。**改动(纯前端低风险)**:队列项加「编辑」按钮 → 点击切 inline input(单行 textarea)→ 回车/失焦写回 `state.queue[idx].text`、ESC 取消;新增 `editQueued(index, newText)`(useAiSend.ts) splice 写回 + store 导出 + composable return。**决策点(待定)**:skill 是否可编辑(**倾向只编 text**——skill 来自发送时技能联想,编辑态不暴露 skill 改,简化交互)。i18n 补 edit/save/cancel key。— src/components/AiChat.vue(:511-516 队列项 UI + .ai-queue-item CSS:2856) + src/composables/ai/useAiSend.ts(editQueued 新增) + stores/ai.ts + i18n。
|
- [ ] UX-260616-06 [P2] — **队列消息支持编辑**。用户需求:队列中的消息支持编辑。现状:队列 UI(AiChat.vue:511-516) 每项只有 × 删除(cancelQueued:353),无编辑入口;队列项结构 `{ text, skill?, enqueuedAt }`(stores/ai.ts:70)。**改动(纯前端低风险)**:队列项加「编辑」按钮 → 点击切 inline input(单行 textarea)→ 回车/失焦写回 `state.queue[idx].text`、ESC 取消;新增 `editQueued(index, newText)`(useAiSend.ts) splice 写回 + store 导出 + composable return。**决策点(待定)**:skill 是否可编辑(**倾向只编 text**——skill 来自发送时技能联想,编辑态不暴露 skill 改,简化交互)。i18n 补 edit/save/cancel key。— src/components/AiChat.vue(:511-516 队列项 UI + .ai-queue-item CSS:2856) + src/composables/ai/useAiSend.ts(editQueued 新增) + stores/ai.ts + i18n。
|
||||||
- [ ] UX-260616-07 [P2/依赖 UX-05] — **队列消息支持立即发送**。用户需求:队列中的消息支持立即发送。现状:队列项只能 × 删除或等 drainQueue 串行轮到,无插队。**决策点(待定)——「立即发送」语义**:**(a) 插队=打断当前+立即发这条**(splice 出该项→stopChat→sendMessage 该条;复用 UX-05 stop→AiCompleted→drain 链路,当前未完成回复走 stop 截断保留)vs **(b) 移到队首等当前完成优先**(splice 出→unshift 队首,drainQueue 下一轮先发它,不打断当前)。**倾向 (a)**("立即"字面=马上发,且复用 UX-05 链路;但须注意 splice 后调 stopChat 已不清队列(UX-05),避免已发送项重复入队)。**待 UX-05 打断语义定后同批设计**(共享 stop+drain 链路)。— src/components/AiChat.vue(:511-516 队列项加「立即发送」按钮) + src/composables/ai/useAiSend.ts(sendQueuedNow 新增)。
|
- [ ] UX-260616-07 [P2/依赖 UX-05] — **队列消息支持立即发送**。用户需求:队列中的消息支持立即发送。现状:队列项只能 × 删除或等 drainQueue 串行轮到,无插队。**决策点(待定)——「立即发送」语义**:**(a) 插队=打断当前+立即发这条**(splice 出该项→stopChat→sendMessage 该条;复用 UX-05 stop→AiCompleted→drain 链路,当前未完成回复走 stop 截断保留)vs **(b) 移到队首等当前完成优先**(splice 出→unshift 队首,drainQueue 下一轮先发它,不打断当前)。**倾向 (a)**("立即"字面=马上发,且复用 UX-05 链路;但须注意 splice 后调 stopChat 已不清队列(UX-05),避免已发送项重复入队)。**待 UX-05 打断语义定后同批设计**(共享 stop+drain 链路)。— src/components/AiChat.vue(:511-516 队列项加「立即发送」按钮) + src/composables/ai/useAiSend.ts(sendQueuedNow 新增)。
|
||||||
- [ ] F-260616-15 [P1] — **AI Chat 上下文管理增强:会话分段(清理上下文不删记录)+ 手动上下文压缩**。两个子需求:
|
- [ ] F-260616-15 [P1] — **AI Chat 上下文管理增强:会话分段 + 手动压缩 + 智能裁剪**。三个子需求,详见 [F-15-上下文管理增强设计-2026-06-16.md](./02-架构设计/F-15-上下文管理增强设计-2026-06-16.md)。**2026-06-16 多角度分析完成 + 设计文档定稿**。
|
||||||
|
|
||||||
**① 会话分段(清理上下文,保留记录)**:用户点「清理上下文」后,不清删 DB 消息,而是给当前消息打标记(`status="archived_segment"`),后续新消息作为新段继续同一对话。同一对话内产生 a'/a''/a''' 多段,DB 全量保留可回溯,LLM 上下文只看当前段。**现状**:`ai_chat_clear`(commands.rs:360) 直接 `clear_messages` 置 `messages='[]'`(全删)。`ChatMessage.status`(df-ai-core/provider.rs:59) 已有 `None/"active"/"truncated"` 三态,`is_active()` 过滤 truncated。`ContextManager.build_for_request`(context.rs:219) 发送前已调 `sanitize_messages` 过滤 `!is_active()`。**改动方向**:a) 新增 status 值 `"archived_segment"`(语义=被清理上下文标记,不进 LLM 上下文但前端可见可展开);b) `is_active()` 扩展过滤 archived_segment(不进 build_for_request);c) 新 IPC `ai_chat_clear_context`(区别于 `ai_chat_clear` 全删):给当前所有 active 消息标记 `status="archived_segment"` + 插入一条 system 消息「--- 上下文已清理 ---」做分隔线 + ContextManager 清内存只保留分隔线后的消息;d) 前端:清空按钮拆为「清空对话」(全删,现有)+「清理上下文」(分段标记,新);e) 前端消息列表渲染 archived_segment 段为折叠的灰色分隔区(点击展开看历史段),active 段正常展示。**关键约束**:工具调用三元组不能跨段拆散(整组标记)。
|
**① 会话分段(清理上下文,保留记录)**:用户点「清理上下文」后,不清删 DB 消息,而是给当前消息打标记(`status="archived_segment"`),后续新消息作为新段继续同一对话。同一对话内产生 a'/a''/a''' 多段,DB 全量保留可回溯,LLM 上下文只看当前段。**现状**:`ai_chat_clear`(commands.rs:360) 直接 `clear_messages` 置 `messages='[]'`(全删)。`ChatMessage.status`(df-ai-core/provider.rs:59) 已有 `None/"active"/"truncated"` 三态,`is_active()` 过滤 truncated。`ContextManager.build_for_request`(context.rs:219) 发送前已调 `sanitize_messages` 过滤 `!is_active()`。**改动方向**:a) 新增 status 值 `"archived_segment"`(语义=被清理上下文标记,不进 LLM 上下文但前端可见可展开);b) `is_active()` 扩展过滤 archived_segment(不进 build_for_request);c) 新 IPC `ai_chat_clear_context`(区别于 `ai_chat_clear` 全删):给当前所有 active 消息标记 `status="archived_segment"` + 插入一条 system 消息「--- 上下文已清理 ---」做分隔线 + ContextManager 清内存只保留分隔线后的消息;d) 前端:清空按钮拆为「清空对话」(全删,现有)+「清理上下文」(分段标记,新);e) 前端消息列表渲染 archived_segment 段为折叠的灰色分隔区(点击展开看历史段),active 段正常展示。**关键约束**:工具调用三元组不能跨段拆散(整组标记)。
|
||||||
|
|
||||||
|
|||||||
32
docs/待审查.md
32
docs/待审查.md
@@ -171,13 +171,27 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### CR-260616-44 P0 secret下沉+AiNode provider注入链(方案B FR-S1闭环) — 🟡 待审
|
### CR-260616-44 P0 secret下沉+AiNode provider注入链(方案B FR-S1闭环) — ✅ 已审(PASS)
|
||||||
|
|
||||||
- **范围**: F-260616-07c P0 安全闭环(3 commit:36f70b3 方案 + 766f235 下沉 + 84a6fc4 注入链)。①**secret 下沉**(766f235):df-storage 新建 src/secret.rs move 纯密钥逻辑(get/set/delete/resolve/ensure/migrate+failcount,**不含 build_provider_for** 避循环)+ keyring workspace 平台 feature(windows/apple/linux-native-async-persistent)+ src-tauri secret.rs 瘦身转发壳(pub use df_storage::secret::* + build_provider_for 保留全限定路径调)+ 12 调用点零改(glob 兜底)+ keyring 单测 cfg-gate。②**AiNode 注入链**(84a6fc4):ai_node.rs resolve_provider(db,config)->ResolvedProvider 三路径(provider_id 优先 get_by_id→resolve_provider_secret→ensure / 老明文兼容 warn / 空兜底 list_all find is_default)+ AiNode/AiSelfReviewNode execute 同构经 resolve_provider 拿 provider + schema required [provider_id] + 模板 config 空走兜底。
|
- **复审结论(2026-06-16·CR-44 agent 亲跑 cargo+test+vue-tsc,485s)**: ✅ **PASS** — 🔴0 🟡0 ⚪0
|
||||||
- **维度**: ①secret 下沉边界(纯密钥逻辑无 df-ai 依赖,build_provider_for 留 src-tauri,无循环) ②转发壳 glob 兜底(12 调用点零改核验) ③keyring feature 平台覆盖(Linux linux-native-async-persistent) ④resolve_provider 三路径完备(provider_id/老明文 warn/兜底 is_default/无 provider 友好错误) ⑤**FR-S1 闭环**(api_key 仅 ResolvedProvider 内存,不进 config/NodeOutput/schema required) ⑥schema required 改 [provider_id] ⑦老明文兼容 warn 过渡期 ⑧主代亲跑 cargo df-nodes 79 passed(9 新增 resolve_provider 测试)+ workspace EXIT 0。
|
- **验证**: `cargo check --workspace` EXIT 0(5 warning 全 pre-existing dead_code)/ `cargo test -p df-nodes` **79 passed; 0 failed; 1 ignored**(含 9 个 resolve_provider 新增测试)/ `npx vue-tsc --noEmit` EXIT 0。
|
||||||
|
- **① secret 下沉边界 PASS**:secret.rs:7-9 注释明确不下沉 build_provider_for(依赖 df-ai 会循环)。grep 0 处函数定义,5 处全注释(src-tauri/Cargo.toml:17/secret.rs:7/8)。src-tauri/secret.rs:20 pub use df_storage::secret::* 转发所有下沉函数 + :32 build_provider_for 保留全限定路径调 df_ai::build_provider(:37)。
|
||||||
|
- **② 转发壳 glob 兜底 PASS**:13 个实际调用点(排除注释行)全部经 crate::commands::ai::secret:: 或 super::secret:: 路径调用(commands.rs 4 处+agentic.rs 2 处+title.rs 1 处+knowledge_inject.rs 2 处+idea.rs 1 处+project.rs 2 处+lib.rs 1 处迁移调用)。编译期验证零断裂(cargo check EXIT 0)。
|
||||||
|
- **③ keyring feature 平台覆盖 PASS**:Cargo.toml workspace:24 统一声明 windows-native+apple-native+linux-native-async-persistent 三平台。df-storage/Cargo.toml:18 keyring = { workspace = true }。secret.rs:220 cfg(any(target_os="windows",target_os="macos")) 单测(仅桌面 OS 环境实测 keyring roundtrip,CI 任意 OS 安全)。
|
||||||
|
- **④ resolve_provider 三路径完备 PASS**:ai_node.rs:62-135 路径1 provider_id 优先→get_by_id(:76)→resolve_provider_secret(:143)→ensure_resolved_key(:144)/ 路径2 老明文兼容(:86-121,has_plain_base+has_plain_key 双判+:89-92 warn "老路径将在后续版本移除")/ 路径3 兜底(:124-134,list_all→find is_default→or_else 首条,友误 "未配置 AI Provider")。9 单测全覆盖(by_id/empty_key/unknown_id/legacy_plain/legacy_empty/fallback_default/no_providers/fallback_first/config_model_override)。
|
||||||
|
- **⑤ FR-S1 闭环 PASS**:api_key 仅存 ResolvedProvider 内存(ai_node.rs:45 明文字段,注释 "仅 AiNode 内存可见"),不进 config(schema L313/314 "已废弃过渡" 描述)/NodeOutput(L273-281 仅 text/model/usage 无 api_key)/schema(L316 required 改 ["provider_id"])。resolve_provider_secret(DB 优先→keyring)解析,build_provider(:235-240)传明文仅 AiNode 进程内存可见,不落库不外泄。
|
||||||
|
- **⑥ schema required 改 [provider_id] PASS**:ai_node.rs:316 required=["provider_id"] + :309 description "FR-S1:密钥经 df_storage::secret 解析,不进 config" / AiSelfReviewNode :569 required=["task_id","provider_id"]。
|
||||||
|
- **⑦ 老明文兼容 warn 过渡期 PASS**:ai_node.rs:89-92 tracing::warn!("AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). 老路径将在后续版本移除")。路径2 逻辑保留兼容(:86-121 has_plain_base+has_plain_key 双判)。
|
||||||
|
- **⑧ 迁移逻辑 FAILCOUNT SIDECAR PASS**:secret.rs:34-72 failcount_path/.devflow-keyring-failcount + read/write/record/clear_migration_failcount + migrate_secrets_to_keyring(:125)失败达阈值 3 升级 warn(:136-142 明文滞留风险+3 条排查建议:确认 OS 钥匙串可用/keyring 后端异常/手动重新保存)。lib.rs:28 启动调用非阻断(:29 warn "启动密钥迁移失败(非阻断)")。
|
||||||
|
- **⑨ df-nodes 显式导入 PASS**:ai_node.rs:17 use df_storage::secret::{ensure_resolved_key, resolve_provider_secret} 显式导入(非 glob)。resolve_from_record(:143-144)直接调用。ai_node.rs:14-15 也显式导入 AiProviderRepo/Database/AiProviderRecord。
|
||||||
|
- **⑩ testing 模板 PASS**:task_workflow_templates.rs:61 注册 "ai_self_review" 节点 + :72 edge(ai_self_review→human_review) + 单测(:123-129)验证节点类型+边方向+config 空。state.rs:269-270 注册 AiSelfReviewNode::new(review_db) 闭包捕获正确。
|
||||||
|
- **待修项回流 todo**: **无**(10 维度全 PASS,零问题)。
|
||||||
|
|
||||||
|
- **范围**: F-260616-07c P0 安全闭环(3 commit:36f70b3 方案 + 766f235 下沉 + 84a6fc4 注入链)。①**secret 下沉**(766f235):df-storage 新建 src/secret.rs move 纯密钥逻辑(get/set/delete/resolve/ensure/migrate+failcount,**不含 build_provider_for** 避循环)+ keyring workspace 平台 feature(windows/apple/linux-native-async-persistent)+ src-tauri secret.rs 瘦身转发壳(pub use df_storage::secret::* + build_provider_for 保留全限定路径调)+ 13 调用点零改(glob 兜底)+ keyring 单测 cfg-gate。②**AiNode 注入链**(84a6fc4):ai_node.rs resolve_provider(db,config)->ResolvedProvider 三路径(provider_id 优先 get_by_id→resolve_provider_secret→ensure / 老明文兼容 warn / 空兜底 list_all find is_default)+ AiNode/AiSelfReviewNode execute 同构经 resolve_provider 拿 provider + schema required [provider_id] + 模板 config 空走兜底。
|
||||||
|
- **维度**: ①secret 下沉边界(纯密钥逻辑无 df-ai 依赖,build_provider_for 留 src-tauri,无循环) ②转发壳 glob 兜底(13 调用点零改核验) ③keyring feature 平台覆盖(Linux linux-native-async-persistent) ④resolve_provider 三路径完备(provider_id/老明文 warn/兜底 is_default/无 provider 友好错误) ⑤**FR-S1 闭环**(api_key 仅 ResolvedProvider 内存,不进 config/NodeOutput/schema required) ⑥schema required 改 [provider_id] ⑦老明文兼容 warn 过渡期 ⑧主代亲跑 cargo df-nodes 79 passed(9 新增 resolve_provider 测试)+ workspace EXIT 0。
|
||||||
- **commit**: 36f70b3 + 766f235 + 84a6fc4。
|
- **commit**: 36f70b3 + 766f235 + 84a6fc4。
|
||||||
- **主代独立核查**: ✅ 全过(df-storage secret.rs 无 build_provider 函数定义,grep 5 处全注释 / src-tauri 转发壳 pub use+build_provider_for 全限定路径 / keyring workspace feature 三平台 / resolve_provider 三路径 L74/L89/L123 / schema required L316+L569 / api_key 仅 ResolvedProvider L45/238/464 调 build_provider 不进 NodeOutput / cargo test df-nodes 79 passed 亲跑 / workspace check EXIT 0)。
|
- **主代独立核查**: ✅ 全过(df-storage secret.rs 无 build_provider 函数定义,grep 5 处全注释 / src-tauri 转发壳 pub use+build_provider_for 全限定路径 / keyring workspace feature 三平台 / resolve_provider 三路径 L74/L89/L123 / schema required L316+L569 / api_key 仅 ResolvedProvider L45/238/464 调 build_provider 不进 NodeOutput / cargo test df-nodes 79 passed 亲跑 / workspace check EXIT 0)。
|
||||||
- **审查 agent 待复审重点**: ①secret 下沉后 src-tauri 12 调用点经转发壳路径完整性(lib.rs/commands.rs/agentic.rs + build_provider_for 6 处) ②keyring Linux CI(linux-native-async-persistent 后端依赖 zbus/secret-service) ③resolve_provider 老明文兼容路径后续移除时机 ④FR-S1 端到端验证(实测 tauri dev:testing 模板 ai_self_review 经 resolve_provider 拿 provider→LLM→写 output_json) ⑤⑥端到端联调实测。
|
- **审查 agent 待复审重点**: ①secret 下沉后 src-tauri 13 调用点经转发壳路径完整性(lib.rs/commands.rs/agentic.rs + build_provider_for 6 处) ②keyring Linux CI(linux-native-async-persistent 后端依赖 zbus/secret-service) ③resolve_provider 老明文兼容路径后续移除时机 ④FR-S1 端到端验证(实测 tauri dev:testing 模板 ai_self_review 经 resolve_provider 拿 provider→LLM→写 output_json) ⑤⑥端到端联调实测。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,6 +213,16 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### CR-260616-45 波1 F-01阶段3厂商模型列表拉取 + UX-01错误气泡Fatal区分 + UX-03工具卡按轮次收起 — 🟡 待审
|
||||||
|
|
||||||
|
- **范围**: 波1 workflow(wef0eqdwp,2 agent 并行不同域)。①**F-01 阶段3**(107b848):df-ai 新建 model_fetch.rs(545 行)+ lib.rs:7 注册 pub mod。`fetch_model_names(provider_type,base_url,api_key)->Result<Vec<String>>` 协议分派(openai_compat GET /v1/models Bearer / anthropic_compat x-api-key+anthropic-version 双 header / 其他报错)+ `build_models_url` 智能拼接(尾 /、/v1→/models、/v4、/models 原样、chat/completions→models、/api→/models、根 base→/v1/models)+ `is_non_chat_model` 噪音过滤(13 类:dall-e/midjourney/sd/imagen/tts/whisper/audio/speech/voice/realtime/transcribe/moderation/-search;**embedding 按 §5.4 保留**)+ `filter_chat_models` 去重去空 trim+ `ModelsList` 反序列化(data/models alias + id/name alias + serde default 空兜底)+ `fetch_and_probe` 探测组合(probe 纯函数 map collect)+ 错误映射(timeout→超时提示 / 401/403→鉴权失败 / 404→端点不存在 / 5xx→厂商异常,全含 provider_type+url)。②**UX-01**(bebf2ec):AiChat.vue `canRetry(msg)`(L1940,errorType auth/provider_config=Fatal 隐藏「重试」按钮)+ notRetryable 提示 span(L413)+ v-if="canRetry(msg)" 守卫(L415)。③**UX-03**(bebf2ec):AiChat.vue 新增 `watch(agentRound)`(L1684-1690,cur>prev 收起上一轮工具卡)+ `buildActiveToolIds` 复用(L1644)+ ToolCardList.vue 注释说明 CSS 动画**不做**(flex gap:8px 对零高 item 仍贡献 gap 致双倍间距;display:none 才移出流无 gap;高度 auto→0 不可纯 CSS transition 需 `<Transition>` JS 钩子,本轮野人 lite 不重构 ToolCard 单卡级折叠)。④i18n 4 文件(zh-CN/en × ai.ts/aiChat.ts)notRetryable 等新增 key。
|
||||||
|
- **维度**: ①build_models_url 7 分支完备(尾//v1/v4/models/chat-completions/api/根 base,断言无 /v1/v1/models 重复) ②噪音过滤 13 类 + embedding 保留边界(§5.4) ③反序列化多 alias 兜底(data/models+id/name+serde default 空对象不 panic) ④协议分派(openai_compat Bearer/anthropic_compat 双 header) ⑤fetch_and_probe probe 兜底不中断整体 ⑥错误映射友好提示含上下文 ⑦UX-01 canRetry Fatal 分类(auth/provider_config;**边界:404/参数错映射 unknown 仍可重试**,受 error_type 分类粒度限制,后端下批补 retryable 字段后精确化) ⑧UX-03 agentRound watch 触发时机 + CSS 不动决策合理性(flex gap) ⑨i18n 转义(新增 key 无 @/|/裸 {}) ⑩主代亲跑 `cargo test -p df-ai model_fetch` **24 passed** + `vue-tsc` exit 0。
|
||||||
|
- **commit**: 107b848(F-01 阶段3) + bebf2ec(UX-01+UX-03)。
|
||||||
|
- **主代独立核查**: ✅ 全过(model_fetch.rs 545 行真实存在 + lib.rs:7 `pub mod model_fetch` 注册 / cargo test model_fetch **24 passed** 亲跑 EXIT 0 / 前 120 行结构清晰:公共入口 fetch_model_names/fetch_and_probe + 协议分派 fetch_openai_compat/fetch_anthropic_compat / AiChat.vue canRetry L1940 + notRetryable L413 + v-if L415 / agentRound watch L1684-1690(cur<=prev return 早退) / ToolCardList.vue 注释 flex gap 说明 CSS 不动决策 / vue-tsc exit 0 亲跑 EXIT 0)。
|
||||||
|
- **审查 agent 待复审重点**: ①model_fetch URL 拼接 7 分支边界(尤其根 base→/v1/models 对 GLM v4/DeepSeek/通义根 base 是否正确;`-search` 子串 contains 匹配是否误伤含 -search 的合法 chat 模型名) ②Anthropic 响应格式 GLM 订阅端点同构性(serde 仅取 id 容忍额外字段,留阶段5 IPC 实测) ③fetch_and_probe 无分页(Anthropic has_more 忽略,超大列表>500 可能截断) ④embedding 保留出现在 chat 下拉是否需前端分组(阶段6 前端按 capabilities 含 Embedding 折叠) ⑤UX-01 404/参数错走 unknown 仍可重试(后端补 AiStreamRetry/AiError retryable 字段后精确化,下批 df-ai 串行) ⑥真实厂商 API 联调(401/403/404/超时/解析失败各分支)留阶段5 IPC `ai_fetch_models`(Settings 点「测试连接」触发)端到端验证 ⑦UX-03 CSS 不做是否需后续补 `<Transition>` 动画(P3 体验项)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 已审归档
|
## 已审归档
|
||||||
|
|
||||||
### CR-260616-34 batch58 F-260616-10 provider 默认值重启失效 bug — ⚠ 已审(PASS-WATCH)
|
### CR-260616-34 batch58 F-260616-10 provider 默认值重启失效 bug — ⚠ 已审(PASS-WATCH)
|
||||||
|
|||||||
Reference in New Issue
Block a user