修复: loop_state 懒创建继承+入口早退清理(#10), update_full 剥离列封 lost-update(#11), MidStream 保文不回填半截 reasoning, 标题剥标签 sanitize(#9), ANSI 源头抑制+strip 兜底(#8)
This commit is contained in:
@@ -596,7 +596,13 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
// G1.1(2026-08-05):入口早退也落库 user 消息(调用方已 push 内存),防重启/切会话丢末条 user。
|
||||
// resolved_model 此处尚未算出,model 传 None(仅持久化 user 消息,无 assistant 消息可标)。
|
||||
// #10:loop 即将退出,清 loop_state(spawn 已置 running——若本次 save 懒创建继承了
|
||||
// running,残留会让重启扫描误判「可恢复」,实际 loop 已死无任务可续)。
|
||||
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
|
||||
{
|
||||
let repo = df_storage::crud::AiConversationRepo::new(&db);
|
||||
let _ = repo.set_loop_state(&conv_id, None).await;
|
||||
}
|
||||
// ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth。
|
||||
// 双写收敛经 emit_ai_chat_event;error_type 两端统一 Auth(原 publish 端漏传 None 属漂移,顺带修)。
|
||||
emit_ai_chat_event(&app_handle, AiChatEvent::AiError {
|
||||
@@ -615,7 +621,12 @@ pub(crate) async fn run_agentic_loop(
|
||||
return;
|
||||
}
|
||||
// G1.1(2026-08-05):入口超时早退也落库 user 消息(同上,model None)。
|
||||
// #10:loop 即将退出,清 loop_state(同 Auth 早退分支)。
|
||||
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
|
||||
{
|
||||
let repo = df_storage::crud::AiConversationRepo::new(&db);
|
||||
let _ = repo.set_loop_state(&conv_id, None).await;
|
||||
}
|
||||
tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死");
|
||||
let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string();
|
||||
// 双写收敛经 emit_ai_chat_event;error_type 两端统一 Unknown(原 publish 端漏传 None 属漂移,顺带修)。
|
||||
|
||||
@@ -540,8 +540,10 @@ pub(crate) async fn stream_round(
|
||||
let conv = session.conv(conv_id);
|
||||
let mut msg = ChatMessage::assistant(full_text);
|
||||
msg.model = Some(resolved_model.clone());
|
||||
// MidStream 保文也回填 reasoning_content
|
||||
msg.reasoning_content = round_reasoning_content.clone();
|
||||
// B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传
|
||||
// reasoning 模型(DeepSeek 等)可能引发 thinking 块错配,且残缺推理对下一轮
|
||||
// 无增益;正文截断已由下方「响应不完整」系统提示兜底。
|
||||
msg.reasoning_content = None;
|
||||
// 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage
|
||||
// (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。
|
||||
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。
|
||||
|
||||
@@ -922,7 +922,23 @@ pub(crate) async fn deliver_completion_report_if_waited(app_handle: &AppHandle,
|
||||
return;
|
||||
}
|
||||
let report = crate::commands::ai::agentic::last_active_assistant_text(&app_state.ai_session, conv_id).await;
|
||||
app_state.notify_agent_completion(conv_id, report);
|
||||
app_state.notify_agent_completion(conv_id, report.clone());
|
||||
// ── #14 任务式委托(2026-10-06):完成事件广播 ──
|
||||
// 通知桥 oneshot 只投给单个等待方(编排节点/飞书 handle_message);异步委托两段式下
|
||||
// 飞书侧不再同步等待,改为事件驱动——完成时经 ai_event_bus 广播 AiAgentFinished
|
||||
// (conversation_id=本 conv,summary=最终报告,status=completed),所有订阅方(飞书
|
||||
// completion_watcher/桌面任务卡)各自消费,单一事实源在引擎层(优雅约束②③)。
|
||||
// publish 在 notify 之后:先收 oneshot 的旧等待方(若有)不受影响,广播纯增量。
|
||||
app_state.ai_event_bus.publish_event(AiChatEvent::AiAgentFinished {
|
||||
agent_id: conv_id.to_string(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
summary: report,
|
||||
status: "completed".to_string(),
|
||||
rounds: 0,
|
||||
tokens: 0,
|
||||
rejected_tools: None,
|
||||
stop_kind: Some("converged".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnFn {
|
||||
@@ -960,6 +976,42 @@ pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnF
|
||||
})
|
||||
}
|
||||
|
||||
/// 纠偏注入通道(todo.md 16,2026-10-06):运行中会话的补充指令注入。
|
||||
///
|
||||
/// 语义:target conv 处于 Generating/Compressed(活跃)时,把新消息直接写 per_conv.messages
|
||||
/// (内存真相源)+落库,不改锁语义、不打断 loop——loop 每轮 build_for_request 重新组装上下文
|
||||
/// (agentic/mod.rs:1651),下一轮 LLM 自然看到补充内容(u-claw 补拉机制同构)。
|
||||
///
|
||||
/// 非活跃态返回 Err,调用方(飞书渠道/桌面)自行回退常规 send 路径——本函数不做状态迁移,
|
||||
/// 只做「活跃前提下投递补充」。锁纪律:读态零锁(conv_states 无锁读),push 短锁(与
|
||||
/// finalize_round_output 同款锁模式);落库走 save_conversation 增量路径(per-conv save 锁,
|
||||
/// 与 loop save 串行,无交叉写坏)。
|
||||
///
|
||||
/// 返回 Ok(true)=已注入;Ok(false)=conv 不存在(调用方按普通新消息处理);
|
||||
/// Err(String)=非活跃/注入失败(调用方回退常规 send)。
|
||||
pub(crate) async fn inject_runtime_message(
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
message: &str,
|
||||
) -> Result<bool, String> {
|
||||
// 活跃守卫:仅 Generating/Compressed 接受注入(Stopping 即将退出,注入了也没消费者)
|
||||
let cs = state.conv_states.get(conv_id);
|
||||
if !cs.is_active() {
|
||||
return Err(format!("conv {conv_id} 非活跃态({cs:?}),不走注入"));
|
||||
}
|
||||
let msg = ChatMessage::user(&format!("[补充指令] {message}"));
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if session.conv_read(conv_id).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
session.conv(conv_id).messages.push(msg);
|
||||
drop(session);
|
||||
// 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉
|
||||
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
|
||||
tracing::info!(conv_id = %conv_id, len = message.len(), "[ai] 运行中补充指令已注入,下一轮生效");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 批准/拒绝挂起的工具调用
|
||||
#[tauri::command]
|
||||
pub async fn ai_approve(
|
||||
@@ -2756,4 +2808,23 @@ mod tests {
|
||||
let av: Vec<String> = vec![];
|
||||
assert_eq!(parse_leading_skill("/review xxx", &av), None);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 纠偏注入通道(todo.md 16)—— 前缀语义纯逻辑测试
|
||||
// inject_runtime_message 的 AppState 依赖需完整运行时(全局状态单例),IPC 层
|
||||
// 集成测试成本高;此处锁定注入消息的格式契约(前缀标记让 LLM 区分补充指令与
|
||||
// 正常对话轮),防后续改文案破坏 loop 侧可识别性。
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_inject_message_carries_supplement_marker() {
|
||||
// 注入消息 = ChatMessage::user(&format!("[补充指令] {message}")),前缀是 loop
|
||||
// 侧唯一区分依据(无 role 之外的带外通道),格式即协议
|
||||
let text = "预算改成 5 分钟";
|
||||
let content = format!("[补充指令] {text}");
|
||||
assert!(content.starts_with("[补充指令] "));
|
||||
assert!(content.contains(text));
|
||||
// 原文不被改写(只加前缀,纠偏内容保真)
|
||||
assert_eq!(content.trim_start_matches("[补充指令] "), text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,9 @@ pub async fn ai_conversation_list(
|
||||
"completion_tokens": r.completion_tokens,
|
||||
"created_at": r.created_at,
|
||||
"updated_at": r.updated_at,
|
||||
// V49 可恢复入口:loop_state 透出(running=进行中/parked=中断可恢复/None=无任务),
|
||||
// 前端任务卡据此分区展示(断点续跑闭环读侧,写侧 set_loop_state 既有)
|
||||
"loop_state": r.loop_state,
|
||||
})
|
||||
}).collect();
|
||||
// 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::Manager;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_storage::crud::AiConversationRepo;
|
||||
@@ -384,13 +385,21 @@ async fn save_conversation_inner(
|
||||
|
||||
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
|
||||
// 序列化为 JSON 对象(tool_call_id → PendingApproval),落库供重启/切回时恢复。
|
||||
let pending_approvals_json = {
|
||||
// #10 loop_state 继承(#14 走查):同点顺取 ConvStateStore.is_active——懒创建首次 INSERT 时
|
||||
// 若 loop 在跑(spawn 置 running 早于首次落库,set_loop_state UPDATE 0 行丢失),INSERT
|
||||
// 直继承 running,断点续跑标记不再依赖「落库先于置标」的时序假设。
|
||||
// 判据用 ConvStateStore(AppHandle 进程级 OnceLock,spawn_agent 先例)而非 per_conv:
|
||||
// generating 字段已迁无锁真相源,per_conv 无生命周期字段可读。
|
||||
let (pending_approvals_json, conv_is_active) = {
|
||||
let session = session_arc.lock().await;
|
||||
let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session.pending_approvals
|
||||
.iter()
|
||||
.filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id))
|
||||
.collect();
|
||||
serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
|
||||
let is_active = super::tools::spawn_agent::app_handle()
|
||||
.map(|app| app.state::<crate::state::AppState>().conv_states.is_active(conv_id))
|
||||
.unwrap_or(false);
|
||||
(serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string()), is_active)
|
||||
};
|
||||
|
||||
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
|
||||
@@ -531,7 +540,8 @@ async fn save_conversation_inner(
|
||||
boundary_json: conv_boundary.as_ref()
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.filter(|s| !s.is_empty()),
|
||||
loop_state: None,
|
||||
// #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失)
|
||||
loop_state: if conv_is_active { Some("running".to_string()) } else { None },
|
||||
};
|
||||
if let Err(e) = conv_repo.insert(rec).await {
|
||||
tracing::warn!("落库对话失败 {conv_id}: {e}");
|
||||
|
||||
@@ -236,34 +236,67 @@ fn clean_title(raw: &str) -> String {
|
||||
///
|
||||
/// 开头常出现测试/问候("1"、"你好"等),取最长消息自然跳过这些噪音。
|
||||
/// 极端情况全部过短时(仅测试消息),用第一条非空 user 兜底。
|
||||
/// #9 标题泄漏:提取前剥离 HTML/Markdown 标签(<details>/<summary>/代码围栏标记等),
|
||||
/// 治「content 首行是标签结构体,截断后标题裸露 `<details> <summa…`」。
|
||||
fn sanitize_title_source(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'<' => {
|
||||
// HTML 标签:吞到 '>'(含);残缺(无 '>')吞到行尾
|
||||
let mut closed = false;
|
||||
for n in chars.by_ref() {
|
||||
if n == '>' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if n == '\n' && !closed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = closed;
|
||||
}
|
||||
'`' => {
|
||||
// 代码围栏/行内标记:跳过反引号本身(保留代码文本语义)
|
||||
}
|
||||
'#' | '*' | '_' | '~' if out.is_empty() || out.ends_with(|ch: char| ch.is_whitespace()) => {
|
||||
// 行首 Markdown 修饰符(# 标题/* 列表/_ 强调)不进标题;行中出现的保留(可能是正常标点)
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
// 压缩空白(标签剥离后可能留下连续空格/空行)
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
|
||||
const MAX_TITLE_CHARS: usize = 60;
|
||||
const SCAN_LIMIT: usize = 5;
|
||||
|
||||
let user_msgs: Vec<&str> = messages.iter()
|
||||
let user_msgs: Vec<String> = messages.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.take(SCAN_LIMIT)
|
||||
.map(|m| m.content.trim())
|
||||
.map(|m| sanitize_title_source(m.content.trim()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// 取字符数最多的
|
||||
let best = user_msgs.iter()
|
||||
.max_by_key(|s| s.chars().count())
|
||||
.copied();
|
||||
.max_by_key(|s| s.chars().count());
|
||||
|
||||
match best {
|
||||
Some(text) => {
|
||||
let mut chars = text.chars();
|
||||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_some() { Some(format!("{}...", t)) } else { Some(t) }
|
||||
}
|
||||
None => {
|
||||
} None => {
|
||||
// 极端情况全空 → 第一条非空 user(兜底)
|
||||
messages.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::User) && !m.content.trim().is_empty())
|
||||
.map(|m| {
|
||||
let mut chars = m.content.trim().chars();
|
||||
let sanitized = sanitize_title_source(m.content.trim());
|
||||
let mut chars = sanitized.chars();
|
||||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user