新增: 连发消息防抖与长命令后台化

- 飞书连发消息 0.5s 窗(拍板值)合并为一次任务:文本空行拼接+图片到达序合并
- 命令/审批/注入即时路径不进窗;追加消息并入即静默返回;窗满统一受理,守卫拒绝落待发箱
- run_command 超 grace 窗(默认 90s,可配)转后台:LLM 收非终态结果,不再整轮阻塞
- 后台任务自持心跳+删除自中止+失败记忆+完成通知+回填续跑;占用闸单点保会话态自洽
- 开关默认关=同步路径逐字节不变;新增 13 测
This commit is contained in:
lxy
2026-10-11 12:37:23 +08:00
parent fda991cbe1
commit c7d0ed939c
4 changed files with 894 additions and 90 deletions
+173 -12
View File
@@ -117,6 +117,9 @@ struct Ctx {
chat_map: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
/// per-chat 串行锁:同会话消息排队处理,防通知桥单槽跨消息串扰(P1-1)
chat_locks: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
/// 防抖聚合窗:chat_id → 窗内追加消息蓄积。首条登记,追加并入即返回,
/// 窗满首条取走统一受理;生命周期同 chat_locks(渠道重启随 Ctx 重建)
debounce: Arc<tokio::sync::Mutex<HashMap<String, DebounceEntry>>>,
/// D3 活性时间戳(unix 秒):入站事件与连接建立成功时更新,看门狗据此判事件断流
last_alive: Arc<AtomicI64>,
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
@@ -176,6 +179,7 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
whitelist: Arc::new(tokio::sync::RwLock::new(cfg.whitelist)),
chat_map: Arc::new(tokio::sync::Mutex::new(restored)),
chat_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
debounce: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
last_alive: Arc::new(AtomicI64::new(now_secs())),
inflight: Arc::new(AtomicUsize::new(0)),
});
@@ -469,7 +473,115 @@ fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
}
}
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
/// 防抖窗口(拍板 0.5s):同 chat 连发消息的合并等待时长
const DEBOUNCE_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
/// 防抖窗内一条 chat 的追加消息蓄积。首条原文/图片不入此(留在本地,窗满时合并),
/// 结构上保证窗满取走必得 entry 且最坏退化为单条,消息不丢
#[derive(Default)]
struct DebounceEntry {
/// 追加消息合并文本(空行拼接)
text: String,
/// 追加消息图片片(到达序合并)
parts: Option<Vec<ContentPart>>,
/// 已追加条数(不含首条)
appended: usize,
}
impl DebounceEntry {
/// 追加一条进窗:文本空行拼接、图片片到达序合并、计数累加
fn append(&mut self, text: &str, parts: Option<Vec<ContentPart>>) {
self.text = merge_debounce_text(&self.text, text);
self.parts = merge_debounce_parts(self.parts.take(), parts);
self.appended += 1;
}
}
/// 防抖合并纯函数:文本空行拼接(任一侧为空直取另一侧,不产首尾空行)
fn merge_debounce_text(base: &str, next: &str) -> String {
if base.is_empty() {
next.to_string()
} else if next.is_empty() {
base.to_string()
} else {
format!("{base}\n\n{next}")
}
}
/// 防抖合并纯函数:图片片按到达序拼接(单侧 None 直取另一侧)
fn merge_debounce_parts(
base: Option<Vec<ContentPart>>,
next: Option<Vec<ContentPart>>,
) -> Option<Vec<ContentPart>> {
match (base, next) {
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
(a, b) => a.or(b),
}
}
/// 窗满合并结果(首条视角):合并后任务载荷+占位卡句柄,交回 handle_message 续原流程
struct MergedIncoming {
text: String,
parts: Option<Vec<ContentPart>>,
placeholder_id: Option<String>,
}
/// 连发消息防抖聚合(u-claw 同款)。防抖点在注入旁路之后:命令/审批/注入即时路径不进窗,
/// 白名单与图片下载已在前逐条完成。追加消息并入 entry 即返回 None(调用方直接收尾,
/// 不回卡防刷屏——首条占位卡承载合并提示,不取 per-chat 锁不 spawn,受理计数随 RAII
/// 回落);首条登记空 entry 后回占位卡(文案不变)等窗,窗满取走 entry 返回合并结果,
/// 有追加时占位卡原地 PATCH 合并提示。sleep 全程不持任何锁;取走与追加的互斥由
/// DebounceMap 锁保证,窗满后到达的消息查 None 自成新窗,自洽
async fn debounce_collect(
ctx: &Ctx,
chat_id: &str,
message_id: &str,
text: String,
parts: Option<Vec<ContentPart>>,
) -> Option<MergedIncoming> {
{
let mut map = ctx.debounce.lock().await;
if let Some(entry) = map.get_mut(chat_id) {
entry.append(&text, parts);
return None;
}
map.insert(chat_id.to_string(), DebounceEntry::default());
}
// 立即确认(交互丝滑):占位单行卡片原地更新为结果,占位与结果合一不刷屏;
// reply 失败置空,完成路径回退新发卡片。带任务原文回显,让用户在到受理卡的
// 无内容可对证期即时确认消息被正确接收;摘要先取(text 随后参与合并 move)
let placeholder_text = format!("🤔 已接收,处理中…\n任务:{}", truncate(&text, 60));
let placeholder_id =
reply_markdown_card(&ctx.client, message_id, &placeholder_text).await;
tokio::time::sleep(DEBOUNCE_WINDOW).await;
// 取走窗内蓄积。entry 仅本首条插入、追加只改不摘,None 理论不可达,兜底退化为单条
let entry = ctx
.debounce
.lock()
.await
.remove(chat_id)
.unwrap_or_default();
if entry.appended > 0 {
deliver_result(
&ctx.client,
&placeholder_id,
message_id,
chat_id,
&format!("📥 已合并 {} 条消息,开始处理", entry.appended + 1),
)
.await;
}
Some(MergedIncoming {
text: merge_debounce_text(&text, &entry.text),
parts: merge_debounce_parts(parts, entry.parts),
placeholder_id,
})
}
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;连发消息防抖合并;其余走 ai-chat 会话链路
async fn handle_message(
ctx: Arc<Ctx>,
msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage,
@@ -740,14 +852,15 @@ async fn handle_message(
// chat_id → conv_id 映射(内存命中优先,未命中新建)
feishu_log(&format!("收到消息 chat={chat_id} open_id={open_id} text={}", truncate(&text, 80)));
// 立即确认(交互丝滑):占位改用单行卡片并记下其 message_id,完成后原地更新为结果,
// 占位与结果合一不刷屏;占位 reply 失败则置空,完成路径回退现状(新发卡片)
// 2026-10-08 占位卡带任务原文回显:原固定「🤔 已接收,处理中…」用户不知在做什么,
// 到受理卡之间(per-chat 锁排队 + spawn)有一段无内容可对证期;回显摘要让用户即时确认
// 消息已被正确接收。text 此后会被 move 进 spawn_agentic_session,此处先取摘要
let placeholder_text = format!("🤔 已接收,处理中…\n任务:{}", truncate(&text, 60));
let placeholder_id =
reply_markdown_card(&ctx.client, &message_id, &placeholder_text).await;
// ── 连续消息防抖聚合(u-claw 同款):同 chat 0.5s 窗内连发合并为一次任务 ──
// 追加消息在此收尾(None),窗满首条携合并载荷继续下方原流程(per-chat 锁→conv
// 映射→受理守卫→spawn/959)
let Some(merged) = debounce_collect(&ctx, &chat_id, &message_id, text, parts).await else {
return;
};
let MergedIncoming { text, parts, placeholder_id } = merged;
// per-chat 串行:同会话消息排队(防通知桥单槽被后条覆盖致前条串扰)
let chat_lock = {
let mut locks = ctx.chat_locks.lock().await;
@@ -2288,9 +2401,10 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)]
mod tests {
use super::{
delivery_blocked, extract_delivery_paths, feishu_file_type, heartbeat_due,
is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
sniff_image_mime, strip_mention_placeholders, stream_preview,
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
heartbeat_due, is_terminal_push, md_to_lark_friendly, merge_debounce_parts,
merge_debounce_text, msg_after_acceptance, parse_command, sniff_image_mime,
strip_mention_placeholders, stream_preview,
};
#[test]
@@ -2542,4 +2656,51 @@ mod tests {
assert!(!is_terminal_push("⏳ 进行中"));
assert!(!is_terminal_push("普通进度行"));
}
// ── 防抖合并 ──
#[test]
fn debounce_text_merges_with_blank_line() {
// 多文本空行拼接;空侧直取另一侧不产首尾空行;追加路径逐条 append 的链式形态
assert_eq!(merge_debounce_text("第一段", "第二段"), "第一段\n\n第二段");
assert_eq!(merge_debounce_text("", "第二段"), "第二段");
assert_eq!(merge_debounce_text("第一段", ""), "第一段");
let mut merged = merge_debounce_text("a", "b");
merged = merge_debounce_text(&merged, "c");
assert_eq!(merged, "a\n\nb\n\nc");
}
#[test]
fn debounce_parts_merge_in_arrival_order() {
// 图片片按消息到达序合并;单侧 None 直取另一侧;双 None 保持 None
let img1 = ContentPart::image_url("https://x/1.png");
let img2 = ContentPart::image_url("https://x/2.png");
let merged = merge_debounce_parts(
Some(vec![ContentPart::text("图1说明"), img1.clone()]),
Some(vec![img2.clone()]),
);
assert_eq!(merged, Some(vec![ContentPart::text("图1说明"), img1, img2.clone()]));
assert_eq!(
merge_debounce_parts(None, Some(vec![img2.clone()])),
Some(vec![img2.clone()])
);
assert_eq!(merge_debounce_parts(Some(vec![]), None), Some(vec![]));
assert_eq!(merge_debounce_parts(None, None), None);
}
#[test]
fn debounce_entry_counts_appends() {
// N 计数只含追加(首条不入 entry):3 条追加=合并 4 条受理;文本与图片同步蓄积
let mut entry = DebounceEntry::default();
assert_eq!(entry.appended, 0);
entry.append("第二条", None);
entry.append("第三条", Some(vec![ContentPart::image_url("https://x/2.png")]));
entry.append("第四条", None);
assert_eq!(entry.appended, 3);
assert_eq!(entry.text, "第二条\n\n第三条\n\n第四条");
assert_eq!(
entry.parts,
Some(vec![ContentPart::image_url("https://x/2.png")])
);
}
}