diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index e871028..895e71a 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -823,6 +823,15 @@ pub(crate) async fn run_agentic_loop( } } + // 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations + let pinned_goals_snapshot: Vec = { + let session = session_arc.lock().await; + session + .conv_read(&conv_id) + .map(|c| c.pinned_goals.clone()) + .unwrap_or_default() + }; + // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); @@ -854,7 +863,7 @@ pub(crate) async fn run_agentic_loop( spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); guard.reset().await; // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); // L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, @@ -862,6 +871,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), + pinned_goals: pinned_goals_snapshot.clone(), }); return; } @@ -1432,6 +1442,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()), incomplete: Some(true), + pinned_goals: pinned_goals_snapshot.clone(), }); // L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。 let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { @@ -1440,6 +1451,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), + pinned_goals: pinned_goals_snapshot.clone(), }); return; } @@ -1500,7 +1512,7 @@ pub(crate) async fn run_agentic_loop( spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); guard.reset().await; // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); // L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。 let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, @@ -1508,6 +1520,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), + pinned_goals: pinned_goals_snapshot.clone(), }); return; } @@ -1813,7 +1826,7 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内, // 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { @@ -1822,6 +1835,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), + pinned_goals: pinned_goals_snapshot.clone(), }); } @@ -1939,12 +1953,14 @@ pub(crate) async fn try_continue_agent_loop( let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false); let agent_language = conv.and_then(|c| c.agent_language.clone()); let model_override = conv.and_then(|c| c.model_override.clone()); + let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default(); ContinueSnapshot { is_generating, has_pending, pending_conv_id, agent_language, model_override, + pinned_goals_snapshot, } }; let should_continue = snap.is_generating && !snap.has_pending; @@ -1971,6 +1987,7 @@ pub(crate) async fn try_continue_agent_loop( completion_tokens: 0, incomplete: None, conversation_id: Some(emit_conv_id), + pinned_goals: snap.pinned_goals_snapshot.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp @@ -2062,6 +2079,7 @@ pub(crate) async fn try_continue_agent_loop( completion_tokens: 0, incomplete: None, conversation_id: Some(conv_id_owned.clone()), + pinned_goals: snap.pinned_goals_snapshot.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp @@ -2097,6 +2115,8 @@ struct ContinueSnapshot { pending_conv_id: Option, agent_language: Option, model_override: Option, + /// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带 + pinned_goals_snapshot: Vec, } #[cfg(test)] diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 976c472..36bff09 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -1459,7 +1459,7 @@ pub async fn ai_chat_force_send( // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板), // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他 // 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。 - let (old_conv_id, conv_id, _user_message_id) = { + let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = { let mut session = state.ai_session.lock().await; // 目标 conv:入参优先 → active → 懒创建。 let target = conversation_id.clone().filter(|s| !s.is_empty()) @@ -1540,7 +1540,7 @@ pub async fn ai_chat_force_send( } } } - (was_gen.then_some(target.clone()), target, user_msg_id) + (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone()) }; // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit) @@ -1551,6 +1551,7 @@ pub async fn ai_chat_force_send( completion_tokens: 0, incomplete: None, conversation_id: Some(cid.clone()), + pinned_goals: old_pinned_goals.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 @@ -1649,10 +1650,12 @@ pub async fn ai_chat_stop( ), } conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 + let pinned_goals = conv.pinned_goals.clone(); drop(session); let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, conversation_id: Some(target), + pinned_goals, }; let _ = app.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 @@ -1701,6 +1704,7 @@ pub async fn ai_chat_stop( "[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)" ), } + let pinned_goals = conv.pinned_goals.clone(); drop(session); // 释放锁后再 emit,避免持锁调 runtime emit let ev = AiChatEvent::AiCompleted { total_tokens: 0, @@ -1708,6 +1712,7 @@ pub async fn ai_chat_stop( completion_tokens: 0, incomplete: None, conversation_id: conv_id, + pinned_goals, }; let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 @@ -1772,6 +1777,7 @@ pub async fn ai_stop_loop( state: State<'_, AppState>, conversation_id: String, ) -> Result { + let pinned_goals: Vec; { let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 @@ -1794,6 +1800,7 @@ pub async fn ai_stop_loop( "[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)" ), } + pinned_goals = conv.pinned_goals.clone(); } // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) let ev = AiChatEvent::AiCompleted { @@ -1802,6 +1809,7 @@ pub async fn ai_stop_loop( completion_tokens: 0, incomplete: None, conversation_id: Some(conversation_id), + pinned_goals, }; let _ = app.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 1ee4d0f..5274aec 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -138,6 +138,8 @@ pub enum AiChatEvent { /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 incomplete: Option, conversation_id: Option, + /// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新) + pinned_goals: Vec, }, /// 错误 /// diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 958deae..8ccf855 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,6 +89,13 @@ pub fn run() { } } } + // 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带) + let pinned_goals_map: std::collections::HashMap> = dirty_convs + .iter() + .filter_map(|cid| { + session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone())) + }) + .collect(); // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, // 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending), // 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。 @@ -107,6 +114,7 @@ pub fn run() { completion_tokens: 0, incomplete: None, conversation_id: Some(cid.clone()), + pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(), }, ); } diff --git a/src/api/types.ts b/src/api/types.ts index 383c97c..b277843 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -372,7 +372,7 @@ export type AiChatEvent = ({ } | { // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断), // 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。 - type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean + type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[] } | { type: 'AiError'; error: string; error_type?: AiErrorType } | { diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 8815dd0..8aaaa28 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -580,7 +580,11 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { timestamp: Date.now(), }) } - // 对话透明化 L1:刷新会话列表(更新 pinned_goals) + // 对话透明化 L1:直接从事件更新 pinned_goals,不等 loadConversations 异步刷新 + if (event.pinned_goals && state.activeConversationId) { + const conv = state.conversations.find(c => c.id === state.activeConversationId) + if (conv) conv.pinned_goals = event.pinned_goals + } void loadConversations() // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 if (isShowTokenUsage()) {