新增: F-15阶段2手动上下文管理(2 IPC+3事件+前端按钮+status渲染)

This commit is contained in:
2026-06-17 01:09:41 +08:00
parent 7fd29e68c3
commit 4194842561
10 changed files with 751 additions and 36 deletions

View File

@@ -384,6 +384,191 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
Ok(())
}
/// 手动上下文分段(F-15 阶段2):归档保护区外消息,不删 DB。
///
/// 把保护区外(active)的消息经 messages_mut 标 `status="archived_segment"`
/// (is_active 自动 false,sanitize 隔离不进 LLM 上下文,前端按 is_active 过滤折叠),
/// 保护区最近 N 条 active 不动(用户当前上下文连续性)。落库持久化新 status,不删 DB。
///
/// 空会话/无 active 可分段消息 → emit AiContextCleared 后 noop 返回 Ok(不报错)。
///
/// 与 ai_chat_clear 区别:clear 真删 DB 全清;本命令只软分段(归档),保留可追溯历史。
#[tauri::command]
pub async fn ai_chat_clear_context(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
// 活跃对话一致性:仅对当前活跃对话分段(防陈旧快照标错对话的消息)。
// 与 ai_chat_send/ai_regenerate 取 session 模式一致(state.ai_session.lock)。
let conv_id = {
let mut session = state.ai_session.lock().await;
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法分段".to_string());
}
// 保护区:保留最近 PROTECT_COUNT 条 active。
// PROTECT_COUNT 是 df-ai context.rs 私有常量(=6,build_for_request 同款保护区),
// 未导出到 crate 外,此处用同值字面量 + 注释指明出处,与 build_for_request 语义一致。
const PROTECT_COUNT: usize = 6;
let protect_start = session.messages.len().saturating_sub(PROTECT_COUNT);
// 空会话/全在保护区(消息 ≤ N 条) → 无可分段消息,emit 后 noop
if protect_start == 0 {
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
conversation_id: Some(conversation_id.clone()),
});
return Ok(());
}
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
// history_tokens 同步:ContextManager 未暴露 history_tokens 写 setter,但 push/compress_old_messages
// 内部用 saturating_sub 维护。此处经 messages_mut 改 status 后 history_tokens 会与 active 集脱钩,
// 但 build_for_request 超预算裁剪路径仍按 history_tokens 决策——为保 token 预算一致性,
// 采用"扣减后等价"的近似:记录被标 archived_segment 消息的 token,经临时 helper 修正。
// df-ai 未提供 history_tokens 减法 API,故此处保留与 compress_old_messages 一致的口径——
// 不直接改 history_tokens(私有字段,无 setter);token 与 active 集可能短暂脱钩,
// 下次 build_for_request 时若 history_tokens 偏高只会触发更早裁剪(保守,不劣化安全性)。
for t in session.messages.messages_mut()[..protect_start].iter_mut() {
if t.message.is_active() {
t.message.status = Some("archived_segment".to_string());
}
}
drop(session);
conversation_id
};
// 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id),
});
Ok(())
}
/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
///
/// 流程(失败不阻塞,消息状态不变):
/// 1. emit AiCompressing + set_compressing(true) 防重入
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
/// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要)
/// 5. LLM 失败 → set_compressing(false) + emit AiError(不含 api_key) + 返回 Err;消息状态不变
///
/// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。
#[tauri::command]
pub async fn ai_chat_compress_context(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
language: Option<String>,
) -> Result<(), String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变)
let (conv_id, active_msgs, compress_end) = {
let mut session = state.ai_session.lock().await;
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法压缩".to_string());
}
if session.messages.is_compressing() {
return Err("压缩正在进行中".to_string());
}
// emit 开始 + 置位防重入(成对释放,见下方所有出口)
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressing {
conversation_id: Some(conversation_id.clone()),
});
session.messages.set_compressing(true);
let protect_start = session.messages.len().saturating_sub(PROTECT_COUNT);
if protect_start == 0 {
// 空会话/全在保护区 → 无可压缩消息,set_compressing(false) 后返回 Ok
session.messages.set_compressing(false);
let cid = session.active_conversation_id.clone();
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: cid,
summary: String::new(),
});
return Ok(());
}
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
let active_msgs: Vec<ChatMessage> = session
.messages
.messages_mut()[..protect_start]
.iter()
.filter(|t| t.message.is_active())
.map(|t| t.message.clone())
.collect();
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
(cid, active_msgs, protect_start)
};
if active_msgs.is_empty() {
// 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop
let mut session = state.ai_session.lock().await;
session.messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary: String::new(),
});
return Ok(());
}
// 拿 provider(照 ai_chat_send 的 build_provider_for 模式,密钥经 resolve_provider_secret 闭环,
// FR-S1 api_key 绝不进 payload/日志/错误信息)
let provider = match super::secret::build_provider_for(&provider_config) {
Ok(p) => p,
Err(e) => {
let mut session = state.ai_session.lock().await;
session.messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::ErrorType::Auth),
conversation_id: Some(conv_id),
});
return Err(format!("压缩失败: {}", e));
}
};
let llm_concurrency = state.llm_concurrency.clone();
let summary = match super::compress::compress_via_llm(
provider.as_ref(),
&provider_config,
active_msgs,
&lang,
&llm_concurrency,
).await {
Ok(s) => s,
Err(e) => {
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
let mut session = state.ai_session.lock().await;
session.messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::ErrorType::Unknown),
conversation_id: Some(conv_id),
});
return Err(format!("压缩失败: {}", e));
}
};
// LLM 成功 → 标 compressed(扣 token) + 摘要插首位
{
let mut session = state.ai_session.lock().await;
let _compressed = session.messages.compress_old_messages(compress_end);
session.messages.insert_at(0, ChatMessage::system(&summary));
session.messages.set_compressing(false);
}
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary,
});
Ok(())
}
/// 编辑最后一条 user 消息并重新生成UX-09
///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →

View File

@@ -140,6 +140,13 @@ pub enum AiChatEvent {
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
AiContextCleared { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
AiCompressed { conversation_id: Option<String>, summary: String },
}
// ============================================================

View File

@@ -118,6 +118,9 @@ pub fn run() {
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context,
commands::ai::ai_list_providers,
commands::ai::ai_save_provider,
commands::ai::ai_set_provider,