新增: 飞书文件消息入站与补充注入链完善

- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
This commit is contained in:
lxy
2026-10-15 14:27:22 +08:00
parent 7548896f6d
commit 795935bf6a
10 changed files with 395 additions and 79 deletions
+71 -9
View File
@@ -527,6 +527,31 @@ pub(crate) async fn run_agentic_loop(
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0;
// 真目标源(2026-10-15 目标语义重构):pinned_goals 从「工具推理流水账」改为「用户任务原文」
// ——流水账模式下用户真实意图从不进目标体系,压缩/重启后 AI 即失忆(2026-10-12/15
// 两次丢目标事故同根)。首条任务型 user 消息写入首位;已有目标不覆盖(尊重
// UPDATE_GOALS 显式修正);[系统恢复] 引导与超短碎片不入(递归套娃教训)。
if conv.pinned_goals.is_empty() {
let task_text = conv
.messages
.iter()
.find(|m| {
matches!(m.role, df_ai::provider::MessageRole::User)
&& !m.content.starts_with("[系统恢复]")
&& m.content.chars().count() >= 4
})
.map(|m| {
let mut t: String = m.content.chars().take(GOAL_MAX_CHARS).collect();
if m.content.chars().count() > GOAL_MAX_CHARS {
t.push('…');
}
t
});
if let Some(t) = task_text {
conv.pinned_goals.push(super::GoalEntry::new(t));
tracing::info!(conv_id = %conv_id, "[ai] 会话目标取首条任务消息(目标语义重构)");
}
}
(
conv.stop_flag.clone(),
conv.notify.clone(),
@@ -2013,6 +2038,9 @@ async fn prepare_round_inputs(
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
// 本轮请求已含截至此刻的全部消息,注入计数清零(=已消化);此后到收敛前
// 再到的注入会计数,收敛出口凭 >0 续一轮消化(见收敛分支守卫)。
session.conv(conv_id).pending_injects = 0;
}
let conv = match session.conv_read(conv_id) {
Some(c) => c,
@@ -2218,7 +2246,10 @@ async fn finalize_round_output(
})
.collect();
} else {
update_pinned_goals(&mut session, conv_id, tool_calls_acc);
// 工具推理流水账停写(2026-10-15 目标语义重构):「执行 xxx/查看 xxx」是手段记录
// 不是用户目标,入 pinned_goals 挤占真目标且压缩/重启后即失忆;工具轨迹已由
// 轨迹卡与审计表完整覆盖。目标来源收敛为:首条任务消息(入口写入)+UPDATE_GOALS
// 显式修正(上方分支,LLM 显式目标管理继续有效)。
}
}
}
@@ -2345,19 +2376,49 @@ async fn finalize_round_output(
// 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
if round_truncated_by_max_tokens {
// 注入未消化守卫(2026-10-14 实测):本轮组装后到达的补充指令,纯问答轮一轮收敛即
// 无人消费(本轮上下文已在生成中定型)。计数>0 → 插提示续一轮,下轮
// build_for_request 自然带上补充内容;计数已 take 清零,同批注入不重复触发。
let pending_injects = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
std::mem::take(&mut session.conv(conv_id).pending_injects)
} else {
0
}
};
if pending_injects > 0 {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut notice = ChatMessage::system(
"⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。",
);
notice.model = Some(resolved_model.to_string());
conv.messages.push(notice);
let mut tip = ChatMessage::system(format!(
"用户在回答生成期间补充了 {pending_injects} 条消息(见对话记录中的补充指令);\
请结合补充内容继续或修正上面的回答,勿忽略。"
));
tip.model = Some(resolved_model.to_string());
conv.messages.push(tip);
}
tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示");
tracing::info!(
conv_id = %conv_id, pending = pending_injects,
"[ai] 收敛时检出未消化注入,插提示续一轮消化"
);
// 不 return Converged:掉到函数尾部 RunTools(本轮无工具空转),
// loop 顶部正常续轮,补充指令获得完整消费轮。
} else {
if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut notice = ChatMessage::system(
"⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。",
);
notice.model = Some(resolved_model.to_string());
conv.messages.push(notice);
}
tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示");
}
return RoundEpilogue::Converged;
}
return RoundEpilogue::Converged;
}
}
@@ -3602,6 +3663,7 @@ fn push_assistant_message(
// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ──
// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。
// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。
#[allow(dead_code)]
fn update_pinned_goals(
session: &mut AiSession,
conv_id: &str,
@@ -1197,6 +1197,9 @@ pub(crate) async fn inject_runtime_message(
if extended {
conv.round_budget_extra += 1;
}
// 未消化计数(2026-10-14 实测):纯问答轮一轮收敛无下一轮,末轮扩轮机制(loop_max>0 才生效)
// 兜不住;收敛出口凭此计数感知「组装后有注入到达」并续一轮消化。
conv.pending_injects = conv.pending_injects.saturating_add(1);
drop(session);
// 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
@@ -1204,6 +1207,20 @@ pub(crate) async fn inject_runtime_message(
Ok(true)
}
/// 桌面端补充注入入口(桌面 AiChat 运行中发消息的 u-claw 语义:补充并进当前回答,不开新轮)。
/// 把 inject_runtime_message 暴露为 Tauri command,与飞书渠道(feishu_channel.rs)同源同一实现,
/// 锁纪律/末轮顺延语义收敛在 inject_runtime_message 单一真值,本 command 纯透传零附加逻辑。
/// 返回契约:Ok(true)=已注入(前端不入队,提示已并入当前回答);Ok(false)=会话不存在
/// (前端按普通新消息入队);Err=非活跃/Stopping(前端零破坏回退常规入队)。
#[tauri::command]
pub async fn ai_inject_runtime(
state: State<'_, AppState>,
conversation_id: String,
message: String,
) -> Result<bool, String> {
inject_runtime_message(&state, &conversation_id, &message).await
}
/// 批准/拒绝挂起的工具调用
#[tauri::command]
pub async fn ai_approve(
+5
View File
@@ -1065,6 +1065,10 @@ pub struct PerConvState {
/// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。
/// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。
pub round_budget_extra: usize,
/// 未消化注入计数(2026-10-14 实测):inject_runtime_message 每次 +1;每轮 build_for_request
/// 组装后清零(消息已进本轮请求=消化);收敛出口查 >0 则不收敛续一轮,治「纯问答轮
/// 一轮收敛,生成期间到达的补充指令无人消费只能等下次请求」的丢失。内存态不落库。
pub pending_injects: u32,
/// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
@@ -1266,6 +1270,7 @@ impl PerConvState {
iteration_used: 0,
loop_max_rounds: 0,
round_budget_extra: 0,
pending_injects: 0,
agent_language: None,
model_override: None,
session_trust: HashSet::new(),