From 302c4bcf521d6261af2b6de015ce5b7a83558c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 18 Oct 2026 23:15:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=86=B3=E8=AE=AE=E7=BB=88=E6=80=81=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E4=B8=8E=E5=86=B3=E8=AE=AE=E8=AF=8D=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F5-1:决议词归一(trim/剥尾标点/同义:批准|同意|拒绝|不同意) - F5-2:审批终态卡原地更新(桌面决议/超时拒后不再恒挂可点); 迟到决议30min窗内提示已处理,不再注入运行中任务 - APPROVAL_CARDS/RESOLVED_CHATS内存句柄,重启后走幂等回执无损失 --- src-tauri/src/feishu_channel.rs | 309 +++++++++++++++++++++++++------- 1 file changed, 247 insertions(+), 62 deletions(-) diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index 66aad2d..f41b6df 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -63,6 +63,34 @@ fn cancel_watchers( CANCEL_WATCHERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) } +/// F5-2 终态窗口:审批前端默认 15min 超时自动拒绝,30min 窗覆盖「终态后用户补发决议词」 +/// 的迟到判定;窗口外的决议词视为普通聊天文本(无审批语境,现状语义) +const RESOLVED_WINDOW_SECS: i64 = 1800; + +/// F5-2 审批终态闭环:已推飞书侧的审批按钮卡句柄(tc_id → (卡片 message_id, 发卡时刻))。 +/// 桌面决议/超时自动拒绝后原卡原地更新为终态,不再恒挂可点。内存态,重启即失——重启后 +/// 陈旧卡点击走 ai_approve 幂等「已处理」回执,无功能损失。写入时顺带清过期条目防无界增长 +static APPROVAL_CARDS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +/// F5-2 迟到决议判定:chat → 最近一次审批终态(时刻, 是否批准)。审批已终态但用户仍在 +/// 飞书回决议词时,据此前 30min 记录提示「已处理」而非把决议文本注入运行中任务 +static RESOLVED_CHATS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn approval_cards() -> &'static std::sync::Mutex> { + APPROVAL_CARDS.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +fn resolved_chats() -> &'static std::sync::Mutex> { + RESOLVED_CHATS.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// 终态新鲜度判定(纯函数,迟到决议提示的窗口判据) +fn within_resolved_window(at_secs: i64, now: i64) -> bool { + now - at_secs < RESOLVED_WINDOW_SECS +} + #[derive(Debug, Clone, Deserialize)] struct FeishuConfig { #[serde(default)] @@ -406,6 +434,27 @@ fn parse_command(text: &str) -> Option<(String, String)> { Some((cmd, tail)) } +/// 审批决议词识别(F5-1):集合对齐按钮卡文案与常见同义词——批准 {同意|批准|通过|approve}, +/// 拒绝 {拒绝|驳回|不同意|reject}。容错链:半全角归一 → 前后空白 → 剥尾部标点(「批准。」 +/// 「同意!」)→ 小写(英文词)。整词精确匹配非包含(「不同意」含「同意」,靠整词比对防反判), +/// 普通文本(「我同意这个方案」)不命中返回 None,由调用方落常规消息路径 +fn parse_approval_verdict(text: &str) -> Option { + const APPROVE_WORDS: [&str; 4] = ["同意", "批准", "通过", "approve"]; + const REJECT_WORDS: [&str; 4] = ["拒绝", "驳回", "不同意", "reject"]; + let t = normalize_halfwidth(text); + let t = t + .trim() + .trim_end_matches(['。', '.', ',', ',', '、', '!', '!', '?', '?', '~', '~', ';', ';', ' ', '\t']) + .to_lowercase(); + if APPROVE_WORDS.contains(&t.as_str()) { + Some(true) + } else if REJECT_WORDS.contains(&t.as_str()) { + Some(false) + } else { + None + } +} + /// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字, /// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」 const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)"; @@ -921,10 +970,13 @@ async fn handle_message( } } - // 24 审批决议通路:该会话有挂起审批时,「批准/拒绝」直调审批原语不入对话链路 - // (决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。决议取该会话最新 - // 一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更 - if text == "批准" || text == "拒绝" { + // 24 审批决议通路(F5-1 决议词容错):该会话有挂起审批时,决议词(同意/批准/通过 = + // 批准;拒绝/驳回/不同意 = 拒绝,含空白/尾标点/大小写容错,集合对齐按钮卡文案)直调 + // 审批原语不入对话链路(决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。 + // 决议取该会话最新一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更——唯一 + // 例外(F5-2 迟到决议):该 chat 近期刚有审批终态(桌面已决议/超时自动取消),决议词 + // 命中却无可拍板对象,回「已处理」提示而非当补充指令注入任务流 + if let Some(approved) = parse_approval_verdict(&text) { let state = ctx.app.state::(); let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned(); let pending = match conv_id { @@ -932,7 +984,6 @@ async fn handle_message( None => None, }; if let Some(tc_id) = pending { - let approved = text == "批准"; feishu_log(&format!("审批决议 chat={chat_id} tc={tc_id} approved={approved}")); // 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过), // 再调 ai_approve,后 spawn watcher 消费 AiToolCallCompleted 拿结果摘要 @@ -958,7 +1009,7 @@ async fn handle_message( None => 0, }; let reply = if remaining > 0 { - format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「批准」或「拒绝」。") + format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「同意 / 批准」或「拒绝」。") } else { reply }; @@ -969,6 +1020,29 @@ async fn handle_message( } return; } + // F5-2 迟到决议:无挂起但该 chat 近期有审批终态——如实提示,不落注入旁路 + let recently_resolved = { + let now = now_secs(); + resolved_chats() + .lock() + .map(|m| { + m.get(&chat_id) + .is_some_and(|(at, _)| within_resolved_window(*at, now)) + }) + .unwrap_or(false) + }; + if recently_resolved { + feishu_log(&format!("迟到决议提示 chat={chat_id} text={}", truncate(&text, 40))); + reply_message( + &ctx.client, + &message_id, + &chat_id, + "ℹ️ 该审批已处理(已批准/已拒绝或超时自动取消),无需再回复;可发 /status 查看任务状态。", + ) + .await; + return; + } + // 近期无审批终态:决议词属普通聊天文本,落常规路径(现状语义保留) } // ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ── @@ -1458,48 +1532,77 @@ async fn run_approval_notifier(ctx: Arc) { let mut rx = state.ai_event_bus.subscribe(); loop { match rx.recv().await { - Ok(v) => { - if v.get("type").and_then(|t| t.as_str()) != Some("AiApprovalRequired") { - continue; - } - let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else { - continue; - }; - let chat_id = ctx - .chat_map - .lock() - .await - .iter() - .find(|(_, c)| c.as_str() == cid) - .map(|(k, _)| k.clone()); - let Some(chat_id) = chat_id else { - continue; - }; - let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("工具"); - let reason = v - .get("reason") - .and_then(|r| r.as_str()) - .unwrap_or("") - .replace('\n', " "); - let brief = truncate(&reason, 80); - // 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片, - // 按钮 value 携带 {approval_id, approved},回调经 on_card_action → - // handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。 - // 文本「批准/拒绝」通路保留作降级(旧卡片/按钮回调异常时仍可用)。 - let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or(""); - if tc_id.is_empty() { - // 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复「批准/拒绝」) - let card = if brief.is_empty() { - format!("⏸ 需审批: `{name}`。回复「批准」或「拒绝」继续") - } else { - format!("⏸ 需审批: `{name}` {brief}。回复「批准」或「拒绝」继续") + Ok(v) => match v.get("type").and_then(|t| t.as_str()) { + // F5-2 审批终态闭环:批/拒/超时(前端超时亦调 ai_approve(false))全路径 emit + // AiApprovalResult——订阅同 bus 复用 spawn_approval_result_watcher 先例,零后端 + // 改动。飞书侧原按钮卡原地更新为终态(不再恒挂可点),并记 chat 维度终态 + // (迟到决议提示判据)。事件无决议来源字段,拒绝文案注明含超时自动取消; + // 飞书侧自身决议同经此收口(与决议回执双保险,防旧卡再点) + Some("AiApprovalResult") => { + let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else { + continue; }; - send_markdown_card(&ctx.client, "", &chat_id, &card).await; - } else { - send_approval_button_card(&ctx.client, &chat_id, tc_id, name, &brief).await; + let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let approved = v + .get("approved") + .and_then(|b| b.as_bool()) + .unwrap_or(false); + let Some(chat_id) = chat_of_conv(&ctx, cid).await else { + continue; + }; + note_approval_resolved(&chat_id, approved); + let mid = approval_cards() + .lock() + .ok() + .and_then(|mut cards| cards.remove(tc_id).map(|(mid, _)| mid)) + .filter(|m| !m.is_empty()); + // 无卡句柄(降级文本卡/他路无原卡):只记终态,迟到提示仍生效 + if let Some(mid) = mid { + let text = if approved { + "✅ 该审批已批准,任务继续执行(按钮已失效)。" + } else { + "🚫 该审批已结束:已拒绝或超时自动取消(按钮已失效)。" + }; + let updated = update_card(&ctx.client, &mid, text).await; + feishu_log(&format!( + "审批终态收口 chat={chat_id} tc={tc_id} approved={approved} 更新={updated}" + )); + } } - feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}")); - } + Some("AiApprovalRequired") => { + let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else { + continue; + }; + let Some(chat_id) = chat_of_conv(&ctx, cid).await else { + continue; + }; + let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("工具"); + let reason = v + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("") + .replace('\n', " "); + let brief = truncate(&reason, 80); + // 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片, + // 按钮 value 携带 {approval_id, approved},回调经 on_card_action → + // handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。 + // 文本决议词通路保留作降级(旧卡片/按钮回调异常时仍可用)。 + let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or(""); + if tc_id.is_empty() { + // 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复决议词) + let card = if brief.is_empty() { + format!("⏸ 需审批: `{name}`。回复「同意 / 批准」或「拒绝」继续") + } else { + format!("⏸ 需审批: `{name}` {brief}。回复「同意 / 批准」或「拒绝」继续") + }; + send_markdown_card(&ctx.client, "", &chat_id, &card).await; + } else { + send_approval_button_card(&ctx.client, &chat_id, tc_id, name, &brief).await; + } + feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}")); + } + _ => {} + }, Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => return, } @@ -1535,15 +1638,8 @@ async fn handle_card_action( if chat_id.is_empty() { let state = ctx.app.state::(); if let Some(cid) = approval_conversation_id(&state, &approval_id).await { - chat_id = ctx - .chat_map - .lock() - .await - .iter() - .find(|(_, c)| c.as_str() == cid) - .map(|(k, _)| k.clone()) - .unwrap_or_default(); - if !chat_id.is_empty() { + if let Some(found) = chat_of_conv(&ctx, &cid).await { + chat_id = found; feishu_log(&format!("卡片回调 chat_id 缺失,反查补齐 chat={chat_id}")); } } @@ -1605,9 +1701,14 @@ async fn handle_card_action( } } +/// F5-3 结果 watcher 超时话术:60s 未见完成事件时回告去向,不静默弃守让用户空等 +const APPROVAL_RESULT_TIMEOUT_MSG: &str = + "⏳ 工具执行耗时较长(超 60s 仍无结果),已停止自动回推;完成后可在桌面端查看该工具的执行结果。"; + /// 审批决议后执行结果 watcher(2026-10-08):订阅 ai_event_bus 的 AiToolCallCompleted /// (匹配 tool_call_id),拿 result 摘要回推 chat。原决议后只回「已批准」,用户不知工具 -/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时静默放弃)。 +/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时回告去向 +/// 后放弃,F5-3 修静默弃守)。 /// /// 订阅须在 ai_approve 调用前完成——publish_event 在无订阅者时跳过序列化,晚订阅必丢事件。 fn spawn_approval_result_watcher( @@ -1622,6 +1723,7 @@ fn spawn_approval_result_watcher( loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { + let _ = send_markdown_card(&ctx.client, "", &chat_id, APPROVAL_RESULT_TIMEOUT_MSG).await; feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}")); return; } @@ -1647,6 +1749,7 @@ fn spawn_approval_result_watcher( Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return, Err(_) => { + let _ = send_markdown_card(&ctx.client, "", &chat_id, APPROVAL_RESULT_TIMEOUT_MSG).await; feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}")); return; } @@ -1665,10 +1768,12 @@ async fn send_approval_button_card( tool_name: &str, brief: &str, ) { + // F5-1 文案统一:副注提示文字决议词(按钮不可用时降级通道),词表与 parse_approval_verdict 同源 + let hint = "点击按钮,或回复「同意 / 批准 / 通过 / 拒绝」"; let detail = if brief.is_empty() { - format!("⏸ 需审批: `{tool_name}`") + format!("⏸ 需审批: `{tool_name}`\n\n{hint}") } else { - format!("⏸ 需审批: `{tool_name}`\n\n{brief}") + format!("⏸ 需审批: `{tool_name}`\n\n{brief}\n\n{hint}") }; // 按钮 value 需为 SDK 的 JsonValue(其实现了 From,直接 into 转换) let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { v.into() }; @@ -1699,7 +1804,7 @@ async fn send_approval_button_card( Ok(d) => d, Err(e) => { tracing::warn!("[feishu] 审批按钮卡校验失败,降级文本卡: {e}"); - let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续"); + let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「同意 / 批准 / 通过 / 拒绝」继续"); send_markdown_card(client, "", chat_id, &fallback).await; return; } @@ -1716,6 +1821,8 @@ async fn send_approval_button_card( .await { Ok(sent) if !sent.message_id.is_empty() => { + // F5-2 登记卡句柄:桌面决议/超时终态事件到达时按 tc_id 原地更新该卡为终态 + note_approval_card(approval_id, &sent.message_id); feishu_log(&format!("审批按钮卡已发 chat={chat_id} card={}", sent.message_id)); } Ok(_) => { @@ -1723,12 +1830,38 @@ async fn send_approval_button_card( } Err(e) => { tracing::error!("[feishu] 审批按钮卡发送失败,降级文本卡: {e}"); - let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续"); + let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「同意 / 批准 / 通过 / 拒绝」继续"); send_markdown_card(client, "", chat_id, &fallback).await; } } } +/// 登记审批按钮卡句柄(终态原地更新用),顺带清窗口外陈旧条目 +fn note_approval_card(tc_id: &str, message_id: &str) { + let now = now_secs(); + let Ok(mut cards) = approval_cards().lock() else { return }; + cards.retain(|_, (_, at)| within_resolved_window(*at, now)); + cards.insert(tc_id.to_string(), (message_id.to_string(), now)); +} + +/// conv → 绑定飞书 chat 反查(notifier 推卡/终态收口共用;无绑定(纯桌面会话)None) +async fn chat_of_conv(ctx: &Ctx, conv_id: &str) -> Option { + ctx.chat_map + .lock() + .await + .iter() + .find(|(_, c)| c.as_str() == conv_id) + .map(|(k, _)| k.clone()) +} + +/// 记录 chat 维度审批终态(迟到决议提示判据),顺带清窗口外陈旧条目 +fn note_approval_resolved(chat_id: &str, approved: bool) { + let now = now_secs(); + let Ok(mut chats) = resolved_chats().lock() else { return }; + chats.retain(|_, (at, _)| within_resolved_window(*at, now)); + chats.insert(chat_id.to_string(), (now, approved)); +} + /// 该 conv 最新一条挂起审批的 tool_call_id(决议原语的消费目标);无挂起返回 None。 /// created_at 缺失视为最旧(宁取有时序的),同刻并列取遍历后者(HashMap 无序,可接受) async fn latest_pending_approval( @@ -2549,8 +2682,8 @@ mod tests { ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type, file_display_name, file_inbound_prompt, file_size_over_limit_error, 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, + msg_after_acceptance, parse_approval_verdict, parse_command, sniff_image_mime, + strip_mention_placeholders, stream_preview, within_resolved_window, }; #[test] @@ -2888,4 +3021,56 @@ mod tests { Some(vec![ContentPart::image_url("https://x/2.png")]) ); } + + // ── F5-1 决议词识别(表驱动) ── + + #[test] + fn verdict_table_approve_and_reject_with_tolerance() { + // 批准集含按钮卡文案「同意」;拒绝集含「不同意」;空白/尾标点/大小写/半全角容错 + let table = [ + // (输入, 期望) + ("同意", Some(true)), + ("批准", Some(true)), + ("通过", Some(true)), + ("approve", Some(true)), + (" 同意 ", Some(true)), + ("批准。", Some(true)), + ("同意!", Some(true)), + ("通过~", Some(true)), + (" 拒绝 ", Some(false)), // 全角空格 + ("REJECT", Some(false)), + ("Approve", Some(true)), // 全角 ASCII 变体 + ("拒绝", Some(false)), + ("驳回", Some(false)), + ("不同意", Some(false)), + ("驳回。", Some(false)), + ("不同意?", Some(false)), + ]; + for (input, expected) in table { + assert_eq!(parse_approval_verdict(input), expected, "case {input:?}"); + } + } + + #[test] + fn verdict_plain_text_not_misjudged() { + // 普通文本/带语气词/长句/子串形态不误判(整词精确匹配,「不同意」含「同意」靠整词防反判) + let plain = [ + "你好", "我同意这个方案", "不同意这个做法", "批准吧", "通不过", "同意一下再看看", + "/status", "拒绝的理由如下", "通过率不高", "", "ok 同意吗", + ]; + for input in plain { + assert_eq!(parse_approval_verdict(input), None, "case {input:?}"); + } + } + + // ── F5-2 迟到决议窗口判定 ── + + #[test] + fn resolved_window_boundary() { + // 30min 窗口内算「刚终态」(迟到决议提示),窗外视为无审批语境(普通消息语义) + assert!(within_resolved_window(1000, 1000 + 1799)); + assert!(within_resolved_window(1000, 1000 + 1800 - 1)); + assert!(!within_resolved_window(1000, 1000 + 1800)); + assert!(!within_resolved_window(1000, 1000 + 7200)); + } }