From 41948425610f775966735c80eab76fd130f5eb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 17 Jun 2026 01:09:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20F-15=E9=98=B6=E6=AE=B52?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E4=B8=8A=E4=B8=8B=E6=96=87=E7=AE=A1=E7=90=86?= =?UTF-8?q?(2=20IPC+3=E4=BA=8B=E4=BB=B6+=E5=89=8D=E7=AB=AF=E6=8C=89?= =?UTF-8?q?=E9=92=AE+status=E6=B8=B2=E6=9F=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/ai/commands.rs | 185 ++++++++++++ src-tauri/src/commands/ai/mod.rs | 7 + src-tauri/src/lib.rs | 3 + src/api/ai.ts | 19 ++ src/components/AiChat.vue | 359 ++++++++++++++++++++--- src/composables/ai/useAiContext.ts | 168 +++++++++++ src/composables/ai/useAiConversations.ts | 4 + src/i18n/en/aiChat.ts | 20 ++ src/i18n/zh-CN/aiChat.ts | 20 ++ src/stores/ai.ts | 2 + 10 files changed, 751 insertions(+), 36 deletions(-) create mode 100644 src/composables/ai/useAiContext.ts diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index 37f7bd8..c58454e 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -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, +) -> 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 = 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 → 校验活跃对话一致 → diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index a4b9e23..a34f059 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -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 }, + /// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记), + /// 前端可据此刷新消息视图(归档段折叠/隐藏)。 + AiContextCleared { conversation_id: Option }, + /// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。 + AiCompressing { conversation_id: Option }, + /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。 + AiCompressed { conversation_id: Option, summary: String }, } // ============================================================ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e213ce..ea38216 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/api/ai.ts b/src/api/ai.ts index fcc90ed..208863d 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -90,6 +90,25 @@ export const aiApi = { return invoke('ai_chat_stop') }, + // ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ── + // 后端经 ai-chat-event emit 生命周期事件: + // ai_context_cleared { conversation_id } — 清空完成 + // ai_compressing { conversation_id } — 压缩开始(loading) + // ai_compressed { conversation_id, summary } — 压缩成功(摘要随事件到前端) + // ai_error { message, conversation_id } — 压缩失败 + // 历史消息落库时 status 标 archived_segment(清空)/ compressed(压缩), + // 前端按 status 折叠成"已归档/已压缩"分隔条(可展开看原文)。 + + /** 清空当前对话上下文(历史消息归档保留,DB 不删;新对话不受影响) */ + clearContext(conversationId: string): Promise { + return invoke('ai_chat_clear_context', { conversationId }) + }, + + /** 压缩当前对话上下文(LLM 摘要落 system 消息 + 历史消息标 compressed 归档) */ + compressContext(conversationId: string, language: string): Promise { + return invoke('ai_chat_compress_context', { conversationId, language }) + }, + /** 列出所有 AI 提供商 */ listProviders(): Promise { return invoke('ai_list_providers') diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 730528c..9f94f77 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -251,6 +251,28 @@ + + + +