新增: 消息级溯源 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:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user