新增: F-15阶段2手动上下文管理(2 IPC+3事件+前端按钮+status渲染)
This commit is contained in:
@@ -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 → 校验活跃对话一致 →
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user