新增: 消息级溯源 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:
@@ -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 IPC(ai_chat_compress_context)/ 阶段3 agentic loop
|
// 这些方法供阶段2 IPC(ai_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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,9 +108,19 @@ 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 未持久化)
|
||||||
conv.generating = false;
|
// 末尾是触发它的 user 消息 → 直接从该 user 重跑(等价重发),不报错。
|
||||||
return Err("没有可重新生成的回复".to_string());
|
// 仅空对话/末尾非 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())
|
.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
Reference in New Issue
Block a user