diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index fcb3809..1f5926f 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -463,6 +463,36 @@ impl ContextManager { self.messages.iter().map(|t| &t.message) } + /// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。 + /// + /// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条 + /// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容: + /// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。 + /// + /// 用途: + /// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id + /// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取) + /// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id + fn last_message_id_by_role(&self, role: MessageRole) -> Option { + self.messages + .iter() + .rev() + .find(|t| { + std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role) + }) + .and_then(|t| t.message.id.clone()) + } + + /// 末条 assistant 消息的 id(消息级溯源用)。 + pub fn last_assistant_message_id(&self) -> Option { + self.last_message_id_by_role(MessageRole::Assistant) + } + + /// 末条 user 消息的 id(消息级溯源用)。 + pub fn last_user_message_id(&self) -> Option { + self.last_message_id_by_role(MessageRole::User) + } + // ── F-15 上下文管理增强辅助方法(阶段1 基础,零行为变化)── // // 这些方法供阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic loop @@ -1175,4 +1205,77 @@ mod tests { unit2.end ); } + + // ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ── + + #[test] + fn last_assistant_message_id_returns_latest() { + // 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("问1")); + let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1")); + mgr.push(ChatMessage::user("问2")); + let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2")); + // 末条 assistant id 应是 last(非 first) + assert_eq!( + mgr.last_assistant_message_id().as_deref(), + Some(last.as_str()), + "应取末条 assistant id, 而非首条" + ); + assert_ne!( + mgr.last_assistant_message_id().as_deref(), + Some(first.as_str()) + ); + } + + #[test] + fn last_user_message_id_returns_latest() { + // 多条 user,反向扫描取末条 id(触发本轮检索的 user) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("问1")); + mgr.push(ChatMessage::assistant("答1")); + let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2")); + assert_eq!( + mgr.last_user_message_id().as_deref(), + Some(last_user.as_str()) + ); + } + + #[test] + fn last_message_id_none_when_no_such_role() { + // 无 assistant → None;无 user → None + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("只有 user")); + assert!( + mgr.last_assistant_message_id().is_none(), + "无 assistant 消息应返 None" + ); + + let mut mgr2 = ContextManager::new(cfg(100_000)); + mgr2.push(ChatMessage::assistant("只有 assistant")); + assert!( + mgr2.last_user_message_id().is_none(), + "无 user 消息应返 None" + ); + } + + #[test] + fn last_message_id_none_for_legacy_no_id() { + // 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式) + let mut mgr = ContextManager::new(cfg(100_000)); + let mut legacy = ChatMessage::assistant("老消息无 id"); + legacy.id = None; + mgr.push(legacy); + assert!( + mgr.last_assistant_message_id().is_none(), + "老消息无 id 应返 None(向前兼容)" + ); + } + + /// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id) + fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String { + let id = message.id.clone(); + mgr.push(message); + id.expect("新构造消息必有 id") + } } diff --git a/src-tauri/src/commands/ai/audit/finalize.rs b/src-tauri/src/commands/ai/audit/finalize.rs index 03313fa..fb32c9b 100644 --- a/src-tauri/src/commands/ai/audit/finalize.rs +++ b/src-tauri/src/commands/ai/audit/finalize.rs @@ -29,15 +29,18 @@ pub(crate) async fn audit_tool_call( risk_level: RiskLevel, result: Option, decided_by: Option<&str>, + message_id: Option<&str>, ) { let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let _ = repo .insert(AiToolExecutionRecord { id: new_id(), conversation_id: Some(conv_id.to_string()), - // F-260619-04 消息级溯源:P0 阶段仅建列,audit 写入暂填 None。 - // P1 将从 ContextManager 取当前 assistant 消息 id 填入(14 处全覆盖)。 - message_id: None, + // F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从 + // ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的 + // assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。 + // None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。 + message_id: message_id.map(|s| s.to_string()), tool_call_id: tool_call_id.to_string(), tool_name: tool_name.to_string(), arguments: arguments.to_string(), diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 5a1c0c6..9a20d9b 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -216,6 +216,16 @@ pub(crate) async fn process_tool_calls( let mut pending_count = 0usize; let audit_repo = AiToolExecutionRepo::new(db); + // F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。 + // 调用前 agentic.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条) + // push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具 + // 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息 + // (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。 + let current_message_id: Option = session + .conv_read(conv_id) + .and_then(|c| c.messages.last_assistant_message_id()); + let current_message_id = current_message_id.as_deref(); + // 解析 args + 批量发 Started(前端骨架按原始 index 顺序展示) let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter() .map(|(idx, draft)| { @@ -273,6 +283,7 @@ pub(crate) async fn process_tool_calls( audit_tool_call( &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "rejected", risk_level, Some(err_msg), Some("auto_blacklist"), + current_message_id, ).await; } @@ -308,6 +319,7 @@ pub(crate) async fn process_tool_calls( audit_tool_call( &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, + current_message_id, ).await; } @@ -381,7 +393,7 @@ pub(crate) async fn process_tool_calls( conversation_id: Some(conv_id.to_string()), }); // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending - audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup")).await; + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await; continue; } } @@ -414,7 +426,7 @@ pub(crate) async fn process_tool_calls( diff: approval_diff, conversation_id: Some(conv_id.to_string()), }); - audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await; + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await; } } } @@ -468,7 +480,7 @@ pub(crate) async fn process_tool_calls( // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); // 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯 - audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust")).await; + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await; } } @@ -521,7 +533,7 @@ pub(crate) async fn process_tool_calls( }; // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); - audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto")).await; + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await; } } diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 4699fe9..9bf84ba 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -108,9 +108,19 @@ pub async fn ai_regenerate( conv.model_override = model_override.clone(); let popped = conv.messages.pop_last_assistant_round(); if !popped { - // 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错 - conv.generating = false; - return Err("没有可重新生成的回复".to_string()); + // 末尾无 AI 回复可弹。失败重试场景(流式中途失败,这轮 assistant 未持久化) + // 末尾是触发它的 user 消息 → 直接从该 user 重跑(等价重发),不报错。 + // 仅空对话/末尾非 user 异常态才真无回复可重生成。 + let last_is_user = conv + .messages + .all_messages_clone() + .last() + .map(|m| matches!(m.role, df_ai::provider::MessageRole::User)) + .unwrap_or(false); + if !last_is_user { + conv.generating = false; + return Err("没有可重新生成的回复".to_string()); + } } } @@ -130,10 +140,15 @@ pub async fn ai_regenerate( .map(|m| m.content.clone()) .unwrap_or_default() }; + // F-260619-04 P1:取末条 user 消息 id 供知识注入 referenced 事件溯源。 + let user_message_id = { + let session = state.ai_session.lock().await; + session.conv_read(&conv_id).and_then(|c| c.messages.last_user_message_id()) + }; let mut system_prompt = system_prompt; { let config = state.knowledge_config.lock().await.clone(); - let knowledge_context = build_knowledge_context(&state, &conv_id, &last_user_text, &config).await; + let knowledge_context = build_knowledge_context(&state, &conv_id, &last_user_text, &config, user_message_id.as_deref()).await; if !knowledge_context.is_empty() { system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); } @@ -215,7 +230,7 @@ pub async fn ai_chat_send( // - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback // active(旧调用方兼容);若 active 也为 None(首次发送),懒创建新 conv id。 // - per_conv 是唯一真相源,删除顶层双写(批2 桥接已退役)。 - let conv_id = { + let (conv_id, user_message_id) = { let mut session = state.ai_session.lock().await; // 目标 conv:入参优先 → active → 懒创建。 let target = conversation_id.clone().filter(|s| !s.is_empty()) @@ -273,7 +288,9 @@ pub async fn ai_chat_send( } else { conv.messages.push(ChatMessage::user(&user_content)); } - target + // F-260619-04 P1:push 后立即取末条 user 消息 id(本轮 user,供知识注入 referenced 溯源)。 + let user_msg_id = conv.messages.last_user_message_id(); + (target, user_msg_id) }; // 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取) @@ -297,7 +314,7 @@ pub async fn ai_chat_send( // 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt] { let config = state.knowledge_config.lock().await.clone(); - let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config).await; + let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config, user_message_id.as_deref()).await; if !knowledge_context.is_empty() { system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); } @@ -922,11 +939,16 @@ pub async fn ai_chat_edit( .map(|m| m.content.clone()) .unwrap_or_default() }; + // F-260619-04 P1:取末条 active user 消息 id 供知识注入 referenced 溯源。 + let user_message_id = { + let session = state.ai_session.lock().await; + session.conv_read(&conv_id).and_then(|c| c.messages.last_user_message_id()) + }; let mut system_prompt = system_prompt; { let config = state.knowledge_config.lock().await.clone(); let knowledge_context = - build_knowledge_context(&state, &conv_id, &last_user_text, &config).await; + build_knowledge_context(&state, &conv_id, &last_user_text, &config, user_message_id.as_deref()).await; if !knowledge_context.is_empty() { system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); } @@ -1003,7 +1025,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) = { + let (old_conv_id, conv_id, user_message_id) = { let mut session = state.ai_session.lock().await; // 目标 conv:入参优先 → active → 懒创建。 let target = conversation_id.clone().filter(|s| !s.is_empty()) @@ -1059,7 +1081,9 @@ pub async fn ai_chat_force_send( } else { conv.messages.push(ChatMessage::user(&user_content)); } - (was_gen.then_some(target.clone()), target) + // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 + let user_msg_id = conv.messages.last_user_message_id(); + (was_gen.then_some(target.clone()), target, user_msg_id) }; // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit) @@ -1088,7 +1112,7 @@ pub async fn ai_chat_force_send( // conv_id 已在上方状态占用块得出。 { let config = state.knowledge_config.lock().await.clone(); - let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config).await; + let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config, user_message_id.as_deref()).await; if !knowledge_context.is_empty() { system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); } diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 74b781f..4d4f567 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -193,11 +193,16 @@ pub(crate) fn merge_hybrid_results( /// /// 流程: 开关检查 → 混合检索 top-3(克制) → 命中条目 reuse_count +1 + 记录引用事件(fire-and-forget) → markdown 格式化 /// 关闭时返回空串(零开销);无结果返回空串。 +/// +/// F-260619-04 P1 消息级溯源:`user_message_id` 为触发本轮检索的 user 消息 id, +/// 传入 referenced 事件溯源(用户问题命中知识)。None 表示无 user 消息/老数据无 id, +/// 降级 conv:{conv_id} 对话级,展示侧兼容。 pub(crate) async fn build_knowledge_context( state: &AppState, conv_id: &str, query: &str, config: &crate::state::KnowledgeConfig, + user_message_id: Option<&str>, ) -> String { if !config.auto_inject { return String::new(); @@ -211,6 +216,7 @@ pub(crate) async fn build_knowledge_context( let ids: Vec = results.iter().map(|r| r.id.clone()).collect(); let conv_id = conv_id.to_string(); let query_clone = query.to_string(); + let user_message_id = user_message_id.map(|s| s.to_string()); tauri::async_runtime::spawn(async move { let repo = KnowledgeRepo::new(&db); let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(&db); @@ -218,7 +224,9 @@ pub(crate) async fn build_knowledge_context( if let Err(e) = repo.increment_reuse_count(id).await { tracing::warn!("reuse_count +1 失败(非阻断): {}", e); } - timeline.record_referenced(id, &conv_id, &query_clone).await; + timeline + .record_referenced(id, &conv_id, &query_clone, user_message_id.as_deref()) + .await; } }); let mut out = String::from("## 相关知识库\n"); @@ -344,6 +352,16 @@ async fn extract_knowledge_from_conversation( return Ok(()); // 太短,不值得提炼 } + // F-260619-04 P1 消息级溯源:取末条 assistant 消息 id(本轮 AI 产出知识的载体)。 + // 反向扫描全量 messages(不限 recent 6 条,确保取到对话最新 assistant,即便它不在 + // 提炼窗口内也属于本轮 AI 产出)。老数据消息无 id → None,source_ref 降级 None, + // 展示侧兼容 conv: 旧格式 + 无 source_ref。 + let last_assistant_msg_id: Option<&str> = messages + .iter() + .rev() + .find(|m| matches!(m.role, MessageRole::Assistant)) + .and_then(|m| m.id.as_deref()); + // 构造提炼消息: system 指令 + 对话内容(user 角色) let mut conv_text = String::new(); for m in recent.iter().rev() { @@ -431,7 +449,13 @@ async fn extract_knowledge_from_conversation( reuse_count: 0, verified: false, source_project: None, - source_ref: Some(format!("conv:{}", conv_id)), + // F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。 + // last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级, + // 展示侧双格式解析兼容。 + source_ref: Some(match last_assistant_msg_id { + Some(mid) => format!("conv_msg:{}", mid), + None => format!("conv:{}", conv_id), + }), reasoning: reasoning.clone(), created_at: now.clone(), updated_at: now, @@ -451,6 +475,7 @@ async fn extract_knowledge_from_conversation( conv_id, &conv_title, reasoning.as_deref().unwrap_or(""), + last_assistant_msg_id, ) .await; } diff --git a/src-tauri/src/commands/knowledge_timeline.rs b/src-tauri/src/commands/knowledge_timeline.rs index 3dbe171..2616440 100644 --- a/src-tauri/src/commands/knowledge_timeline.rs +++ b/src-tauri/src/commands/knowledge_timeline.rs @@ -68,12 +68,17 @@ impl KnowledgeTimeline { } /// AI 对话提炼产生(context: conv_id / conv_title / reasoning) + /// + /// F-260619-04 P1 消息级溯源:`message_id` 为末条 assistant 消息 id(本轮 AI 产出知识的载体)。 + /// - Some(id) → source_ref = `conv_msg:{id}`(消息级) + /// - None(老数据无 id) → source_ref = `conv:{conv_id}`(对话级,向前兼容) pub async fn record_extracted( &self, knowledge_id: &str, conv_id: &str, conv_title: &str, reasoning: &str, + message_id: Option<&str>, ) { let ctx = serde_json::json!({ "conv_id": conv_id, @@ -81,22 +86,40 @@ impl KnowledgeTimeline { "reasoning": reasoning, }) .to_string(); + let source_ref = match message_id { + Some(mid) => format!("conv_msg:{}", mid), + None => format!("conv:{}", conv_id), + }; self.fire( knowledge_id, EVENT_EXTRACTED, - Some(format!("conv:{}", conv_id)), + Some(source_ref), Some(ctx), ) .await; } /// 被检索命中/注入(context: conv_id / query) - pub async fn record_referenced(&self, knowledge_id: &str, conv_id: &str, query: &str) { + /// + /// F-260619-04 P1 消息级溯源:`message_id` 为触发检索的 user 消息 id(用户问题命中知识)。 + /// - Some(id) → source_ref = `conv_msg:{id}`(消息级) + /// - None(老数据无 id) → source_ref = `conv:{conv_id}`(对话级,向前兼容) + pub async fn record_referenced( + &self, + knowledge_id: &str, + conv_id: &str, + query: &str, + message_id: Option<&str>, + ) { let ctx = serde_json::json!({ "conv_id": conv_id, "query": query }).to_string(); + let source_ref = match message_id { + Some(mid) => format!("conv_msg:{}", mid), + None => format!("conv:{}", conv_id), + }; self.fire( knowledge_id, EVENT_REFERENCED, - Some(format!("conv:{}", conv_id)), + Some(source_ref), Some(ctx), ) .await; diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 33bc068..b546105 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -126,11 +126,12 @@ export function friendlyError(raw: string): string { return raw } -/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted 收尾共用) */ +/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */ export function flushCurrentText() { if (!state.currentText) return const last = state.messages[state.messages.length - 1] - if (last && last.role === 'assistant') last.content = state.currentText + // 跳过 isError 气泡(AiStreamRetry 错误气泡):避免部分回复覆盖错误提示 + if (last && last.role === 'assistant' && !last.isError) last.content = state.currentText } /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ @@ -395,6 +396,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { clearStreamWatchdog() clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合 clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程 + // UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡, + // 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。 + flushCurrentText() state.streaming = false state.generatingConvId = null state.currentText = ''