新增: 消息级溯源 P1 + 修复流式失败重试(UX-260619-06)

消息级溯源 P1(溯源字段升级):
- ContextManager 加 last_assistant/last_user_message_id 取消息 id
- audit 6 处 message_id 接真值(替换 P0 None 占位)
- 知识提炼 source_ref 升级 conv_msg:{id}(知识生命线 extracted/referenced 双事件)
- 4 处 build_knowledge_context 调用方传 user message id
- 老数据 id=None 降级 conv:{id} 兼容

流式失败重试修复(UX-260619-06):
- 症状1(回答一半消失):AiError 收尾前 flushCurrentText 保留部分回复到占位气泡
  (原仅 AiAgentRound/AiCompleted 回填,AiError 清 currentText 致部分回复丢失)
- 症状2(重试报"没有可重新生成的回复"):ai_regenerate pop_last_assistant_round 返 false 时
  末尾是 user(流式中途失败这轮 assistant 未持久化)直接从该 user 重跑(等价重发)
  1214 连续 user 已由 merge_consecutive_users(7c98134)修,本批补失败重试链路

自验: workspace EXIT 0 + cargo test 全绿 + vue-tsc EXIT 0
This commit is contained in:
2026-06-19 19:54:16 +08:00
parent cea0c71546
commit d3e7640b8d
7 changed files with 219 additions and 25 deletions

View File

@@ -29,15 +29,18 @@ pub(crate) async fn audit_tool_call(
risk_level: RiskLevel,
result: Option<String>,
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(),

View File

@@ -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<String> = 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;
}
}

View File

@@ -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);
}

View File

@@ -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<String> = 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;
}

View File

@@ -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;