新增: 消息级溯源 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

@@ -463,6 +463,36 @@ impl ContextManager {
self.messages.iter().map(|t| &t.message) 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<String> {
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<String> {
self.last_message_id_by_role(MessageRole::Assistant)
}
/// 末条 user 消息的 id(消息级溯源用)。
pub fn last_user_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::User)
}
// ── F-15 上下文管理增强辅助方法阶段1 基础,零行为变化)── // ── F-15 上下文管理增强辅助方法阶段1 基础,零行为变化)──
// //
// 这些方法供阶段2 IPCai_chat_compress_context/ 阶段3 agentic loop // 这些方法供阶段2 IPCai_chat_compress_context/ 阶段3 agentic loop
@@ -1175,4 +1205,77 @@ mod tests {
unit2.end 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")
}
} }

View File

@@ -29,15 +29,18 @@ pub(crate) async fn audit_tool_call(
risk_level: RiskLevel, risk_level: RiskLevel,
result: Option<String>, result: Option<String>,
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>,
) { ) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
let _ = repo let _ = repo
.insert(AiToolExecutionRecord { .insert(AiToolExecutionRecord {
id: new_id(), id: new_id(),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
// F-260619-04 消息级溯源:P0 阶段仅建列,audit 写入暂填 None。 // F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// P1 将从 ContextManager 取当前 assistant 消息 id 入(14 处全覆盖)。 // ContextManager 取当前 assistant 消息 id 入(LLM 返回带 tool_calls 的
message_id: None, // 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_call_id: tool_call_id.to_string(),
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
arguments: arguments.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 mut pending_count = 0usize;
let audit_repo = AiToolExecutionRepo::new(db); 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 顺序展示) // 解析 args + 批量发 Started前端骨架按原始 index 顺序展示)
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter() let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
.map(|(idx, draft)| { .map(|(idx, draft)| {
@@ -273,6 +283,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"), "rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
current_message_id,
).await; ).await;
} }
@@ -308,6 +319,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"pending", risk_level, None, None, "pending", risk_level, None, None,
current_message_id,
).await; ).await;
} }
@@ -381,7 +393,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending // 审计:去重命中记一条(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; continue;
} }
} }
@@ -414,7 +426,7 @@ pub(crate) async fn process_tool_calls(
diff: approval_diff, diff: approval_diff,
conversation_id: Some(conv_id.to_string()), 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。 // F-260616-09 B 批2:写 per_conv.messages。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
// 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯 // 审计: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。 // F-260616-09 B 批2:写 per_conv.messages。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); 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,11 +108,21 @@ pub async fn ai_regenerate(
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
let popped = conv.messages.pop_last_assistant_round(); let popped = conv.messages.pop_last_assistant_round();
if !popped { if !popped {
// 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错 // 末尾无 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; conv.generating = false;
return Err("没有可重新生成的回复".to_string()); return Err("没有可重新生成的回复".to_string());
} }
} }
}
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -130,10 +140,15 @@ pub async fn ai_regenerate(
.map(|m| m.content.clone()) .map(|m| m.content.clone())
.unwrap_or_default() .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 mut system_prompt = system_prompt;
{ {
let config = state.knowledge_config.lock().await.clone(); 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() { if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); 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 // - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
// active(旧调用方兼容);若 active 也为 None(首次发送),懒创建新 conv id。 // active(旧调用方兼容);若 active 也为 None(首次发送),懒创建新 conv id。
// - per_conv 是唯一真相源,删除顶层双写(批2 桥接已退役)。 // - per_conv 是唯一真相源,删除顶层双写(批2 桥接已退役)。
let conv_id = { let (conv_id, user_message_id) = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active → 懒创建。 // 目标 conv:入参优先 → active → 懒创建。
let target = conversation_id.clone().filter(|s| !s.is_empty()) let target = conversation_id.clone().filter(|s| !s.is_empty())
@@ -273,7 +288,9 @@ pub async fn ai_chat_send(
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); 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 内部按需获取) // 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
@@ -297,7 +314,7 @@ pub async fn ai_chat_send(
// 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt] // 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt]
{ {
let config = state.knowledge_config.lock().await.clone(); 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() { if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); 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()) .map(|m| m.content.clone())
.unwrap_or_default() .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 mut system_prompt = system_prompt;
{ {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
let knowledge_context = 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() { if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); 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(用户当前面板), // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他 // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。 // 后台 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; let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active → 懒创建。 // 目标 conv:入参优先 → active → 懒创建。
let target = conversation_id.clone().filter(|s| !s.is_empty()) let target = conversation_id.clone().filter(|s| !s.is_empty())
@@ -1059,7 +1081,9 @@ pub async fn ai_chat_force_send(
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); 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) // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
@@ -1088,7 +1112,7 @@ pub async fn ai_chat_force_send(
// conv_id 已在上方状态占用块得出。 // conv_id 已在上方状态占用块得出。
{ {
let config = state.knowledge_config.lock().await.clone(); 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() { if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); 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 格式化 /// 流程: 开关检查 → 混合检索 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( pub(crate) async fn build_knowledge_context(
state: &AppState, state: &AppState,
conv_id: &str, conv_id: &str,
query: &str, query: &str,
config: &crate::state::KnowledgeConfig, config: &crate::state::KnowledgeConfig,
user_message_id: Option<&str>,
) -> String { ) -> String {
if !config.auto_inject { if !config.auto_inject {
return String::new(); 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 ids: Vec<String> = results.iter().map(|r| r.id.clone()).collect();
let conv_id = conv_id.to_string(); let conv_id = conv_id.to_string();
let query_clone = query.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 { tauri::async_runtime::spawn(async move {
let repo = KnowledgeRepo::new(&db); let repo = KnowledgeRepo::new(&db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::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 { if let Err(e) = repo.increment_reuse_count(id).await {
tracing::warn!("reuse_count +1 失败(非阻断): {}", e); 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"); let mut out = String::from("## 相关知识库\n");
@@ -344,6 +352,16 @@ async fn extract_knowledge_from_conversation(
return Ok(()); // 太短,不值得提炼 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 角色) // 构造提炼消息: system 指令 + 对话内容(user 角色)
let mut conv_text = String::new(); let mut conv_text = String::new();
for m in recent.iter().rev() { for m in recent.iter().rev() {
@@ -431,7 +449,13 @@ async fn extract_knowledge_from_conversation(
reuse_count: 0, reuse_count: 0,
verified: false, verified: false,
source_project: None, 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(), reasoning: reasoning.clone(),
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
@@ -451,6 +475,7 @@ async fn extract_knowledge_from_conversation(
conv_id, conv_id,
&conv_title, &conv_title,
reasoning.as_deref().unwrap_or(""), reasoning.as_deref().unwrap_or(""),
last_assistant_msg_id,
) )
.await; .await;
} }

View File

@@ -68,12 +68,17 @@ impl KnowledgeTimeline {
} }
/// AI 对话提炼产生(context: conv_id / conv_title / reasoning) /// 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( pub async fn record_extracted(
&self, &self,
knowledge_id: &str, knowledge_id: &str,
conv_id: &str, conv_id: &str,
conv_title: &str, conv_title: &str,
reasoning: &str, reasoning: &str,
message_id: Option<&str>,
) { ) {
let ctx = serde_json::json!({ let ctx = serde_json::json!({
"conv_id": conv_id, "conv_id": conv_id,
@@ -81,22 +86,40 @@ impl KnowledgeTimeline {
"reasoning": reasoning, "reasoning": reasoning,
}) })
.to_string(); .to_string();
let source_ref = match message_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
};
self.fire( self.fire(
knowledge_id, knowledge_id,
EVENT_EXTRACTED, EVENT_EXTRACTED,
Some(format!("conv:{}", conv_id)), Some(source_ref),
Some(ctx), Some(ctx),
) )
.await; .await;
} }
/// 被检索命中/注入(context: conv_id / query) /// 被检索命中/注入(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 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( self.fire(
knowledge_id, knowledge_id,
EVENT_REFERENCED, EVENT_REFERENCED,
Some(format!("conv:{}", conv_id)), Some(source_ref),
Some(ctx), Some(ctx),
) )
.await; .await;

View File

@@ -126,11 +126,12 @@ export function friendlyError(raw: string): string {
return raw return raw
} }
/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted 收尾共用) */ /** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */
export function flushCurrentText() { export function flushCurrentText() {
if (!state.currentText) return if (!state.currentText) return
const last = state.messages[state.messages.length - 1] 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`) */ /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
@@ -395,6 +396,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
clearStreamWatchdog() clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合 clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程 clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
// UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡,
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
flushCurrentText()
state.streaming = false state.streaming = false
state.generatingConvId = null state.generatingConvId = null
state.currentText = '' state.currentText = ''