//! 飞书直连渠道:桌面端出站 WS 长连接(免公网回调),消息进 ai-chat 会话链路 //! //! 形态依据:docs/02-架构设计/专项设计/飞书直连接入-形态修正与问题记录-2026-10-05.md //! (用户 10-05 推翻网关链路,定稿桌面本地直连;参照 u-claw feishu_channel.go 单进程闭环) //! 内核自 crates/df-im-gateway/src/feishu.rs 提炼(用户令:并入现有,不新建 crate); //! 会话复用 spawn_agentic_session 通用核心(pin_active=false,与编排节点同款调用方), //! 完成通知走 AppState 通知桥(register/notify_agent_completion,编排节点先例)。 //! //! 第一批:文本收发(整段回复);流式预览已接入(订阅 ai_event_bus 的 AiTextDelta 节流刷占位卡片)。 use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; use tauri::Manager; use larksuite_oapi_sdk_rs::card::v1::{ Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header, Markdown as MdElement, }; use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy, SendInput}; use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption}; use serde::Deserialize; use df_storage::crud::SettingsRepo; use df_types::types::new_id; use df_ai::provider::ContentPart; /// app_settings 键:飞书渠道配置(与设置面板 FeishuPanel 同键契约) const KV_FEISHU_CONFIG: &str = "feishu-config"; /// app_settings 键:飞书 chat_id → conversation_id 映射(持久化,跨重启续会话) const KV_CHAT_MAP: &str = "feishu-chat-map"; /// #26 占用期话术改实况:委托制下无真排队(不入队),话术禁承诺「任务完成后处理」, /// 如实告知未受理并给出路。#20 受理守卫拒绝与 spawn 并发护栏拒绝共用同一口径 const QUEUE_BUSY_MSG: &str = "⏳ 该会话已有任务占用中,本条未受理(暂不入队);可稍后重发,或先 /status 查看进度。"; /// 19 outbound 统一出口:渠道启动成功后的全局 client 句柄(once 填充,渠道停用为空)。 /// push_to_chat 主动推送复用长连接 client,不临时建连;进程生命周期内渠道只启一次, /// OnceLock 语义匹配(重启应用才可能重配)。 static ACTIVE_CLIENT: std::sync::OnceLock<&'static LarkClient> = std::sync::OnceLock::new(); /// 渠道客户端只读访问(37 监控告警等外部推送方用):None=渠道未启动 pub(crate) fn active_client() -> Option<&'static LarkClient> { ACTIVE_CLIENT.get().copied() } /// 34 滚动汇总卡句柄:chat_id 对 (在途事务卡 message_id, 已累计正文)。同 chat 多步 /// 进度推送原地追加为一卡,防逐条直推刷屏;内存句柄重启即失(重启后首推另起新卡)。 /// std Mutex 只做句柄与字符串存取,不跨 await 持有,短锁语义 static ROLLING_CARDS: std::sync::OnceLock>> = std::sync::OnceLock::new(); /// 24 取消收口旁路:conv_id 对完成订阅任务的取消发送端。引擎停止路径(ai_stop_loop 置 /// stop_flag 后 loop 于轮边界收尾)只 emit AiCompleted,watcher 消费不到;/cancel 后须旁路 /// 唤醒 watcher 投「已停止」终版,防空等至双超时弃守期间反复重刷过期提示。oneshot 消费 /// 一次即摘除,watcher 任意路径退出亦摘除,陈旧条目最多导致一次空发,无泄漏 static CANCEL_WATCHERS: std::sync::OnceLock< tokio::sync::Mutex>>, > = std::sync::OnceLock::new(); fn cancel_watchers( ) -> &'static tokio::sync::Mutex>> { CANCEL_WATCHERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) } #[derive(Debug, Clone, Deserialize)] struct FeishuConfig { #[serde(default)] enabled: bool, #[serde(default)] app_id: String, #[serde(default)] app_secret: String, /// 空 = 放行所有(首启抓 open_id 回填);open_id 按应用隔离,严禁跨应用搬运。 /// 兼容历史字符串形态(逗号分隔):前端旧版曾存字符串,解析失败会静默沿用旧缓存 /// 致白名单改动不生效,故此处 deserialize_with 双形态兼容(数组优先,字符串拆分) #[serde(default, deserialize_with = "de_whitelist")] whitelist: Vec, } /// 白名单反序列化双形态兼容:数组(正典)或逗号分隔字符串(历史脏数据)。 /// 非法形态(数字/对象等)→ 空 Vec(等价放行所有,不 panic 不报错) fn de_whitelist<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { use serde::Deserialize; let v = Option::::deserialize(deserializer)?; Ok(match v { Some(serde_json::Value::Array(arr)) => arr .into_iter() .filter_map(|x| x.as_str().map(|s| s.trim().to_string())) .filter(|s| !s.is_empty()) .collect(), Some(serde_json::Value::String(s)) => s .split(',') .map(|x| x.trim().to_string()) .filter(|x| !x.is_empty()) .collect(), _ => Vec::new(), }) } /// settings 值归一:前端 JSON.stringify 落库带引号,去首尾引号(SettingsNorm 正典同款) fn norm(raw: Option) -> String { raw.map(|s| s.trim().trim_matches('"').to_string()) .unwrap_or_default() } /// 渠道运行上下文(回调闭包共享) struct Ctx { app: tauri::AppHandle, client: LarkClient, /// 白名单缓存(D2 热加载):每消息现读 KV 刷新,读取失败沿用本缓存,改配置免重启 whitelist: Arc>>, /// chat_id → conversation_id(启动时从 KV 恢复,变更回写) chat_map: Arc>>, /// per-chat 串行锁:同会话消息排队处理,防通知桥单槽跨消息串扰(P1-1) chat_locks: Arc>>>>, /// 防抖聚合窗:chat_id → 窗内追加消息蓄积。首条登记,追加并入即返回, /// 窗满首条取走统一受理;生命周期同 chat_locks(渠道重启随 Ctx 重建) debounce: Arc>>, /// D3 活性时间戳(unix 秒):入站事件与连接建立成功时更新,看门狗据此判事件断流 last_alive: Arc, /// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0, /// 看门狗要求为 0 才告警,防有回复待投递时误报 inflight: Arc, } /// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。 pub fn start_if_enabled(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { let db = app.state::().db.clone(); let repo = SettingsRepo::new(&db); let raw = norm(repo.get(KV_FEISHU_CONFIG).await.ok().flatten()); // 早退点全部补 feishu_log:tracing 只进控制台,用户排障只看得到 feishu-channel.log, // 四个早退原因各有不同,不落盘则"渠道未连接"完全盲查(2026-10-05 排查实证) if raw.is_empty() { tracing::info!("[feishu] feishu-config 未配置,渠道不启动"); feishu_log("启动早退: feishu-config 未配置"); return; } let cfg = match serde_json::from_str::(&raw) { Ok(c) => c, Err(e) => { tracing::error!("[feishu] feishu-config 解析失败,渠道不启动: {e}"); feishu_log(&format!("启动早退: feishu-config 解析失败: {e}")); return; } }; if !cfg.enabled || cfg.app_id.is_empty() || cfg.app_secret.is_empty() { let reason = if !cfg.enabled { "enabled=false" } else if cfg.app_id.is_empty() { "app_id 缺失" } else { "app_secret 缺失" }; tracing::info!("[feishu] {reason},渠道不启动"); feishu_log(&format!("启动早退: {reason}")); return; } let client = match LarkClient::builder(&cfg.app_id, &cfg.app_secret).build() { Ok(c) => c, Err(e) => { tracing::error!("[feishu] LarkClient 构造失败: {e}"); feishu_log(&format!("启动早退: LarkClient 构造失败: {e}")); return; } }; // 启动时从 KV 恢复 chat 映射 let restored: HashMap = serde_json::from_str( &norm(repo.get(KV_CHAT_MAP).await.ok().flatten()), ) .unwrap_or_default(); let ctx = Arc::new(Ctx { app, client: client.clone(), 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)), }); // D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都 // Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用 // 引用,重连循环内复用同一 leaked 引用,只重建 Channel/dispatcher let leaked: &'static LarkClient = Box::leak(Box::new(client)); // 19 outbound 统一出口:全局句柄就绪,push_to_chat 自此可用 let _ = ACTIVE_CLIENT.set(leaked); // 24 审批可见性:渠道生命周期订阅任务,审批挂起事件推卡到绑定 chat(跨重连常驻, // 与 run_loop 的按连接重建无关) tauri::async_runtime::spawn(run_approval_notifier(ctx.clone())); // 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死 // 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。 // 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻), // 其后的退出即真断线,推「重新连接」通知;连续失败≥3 推告警。通知目标取 // df-monitor-chat-id(运维通道,与监控告警同键;未配则仅落日志) let mut backoff = 2u64; let mut consecutive_failures = 0u32; loop { let started = tokio::time::Instant::now(); let run_result = run_loop(ctx.clone(), leaked).await; let ran_secs = started.elapsed().as_secs(); let was_connected = ran_secs >= 30; if was_connected { // 曾成功连接 → 本次退出是真断线,重置失败计数并推重连通知 consecutive_failures = 0; let reason = match &run_result { Err(e) => format!("{e}"), Ok(_) => "连接正常关闭".to_string(), }; tracing::warn!("[feishu] 长连接断开(运行 {ran_secs}s),{}s 后重连: {reason}", backoff); feishu_log(&format!("长连接断开(运行 {ran_secs}s),{}s 后重连: {reason}", backoff)); notify_monitor(&ctx, &format!("⚠️ 飞书渠道连接断开,{backoff}s 后自动重连中…")).await; } else { consecutive_failures += 1; let reason = match &run_result { Err(e) => format!("{e}"), Ok(_) => "连接立即关闭".to_string(), }; tracing::error!("[feishu] 长连接快速退出(运行 {ran_secs}s,连续失败 {consecutive_failures}),{}s 后重连: {reason}", backoff); feishu_log(&format!("长连接快速退出(运行 {ran_secs}s,连续失败 {consecutive_failures}),{}s 后重连: {reason}", backoff)); if consecutive_failures == 3 { notify_monitor(&ctx, "⚠️ 飞书渠道连接不稳定(连续 3 次快速断开),后台持续重连中,请检查网络或凭证配置。").await; } } tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; backoff = (backoff * 2).min(300); } }); } /// 运维通知:推给 df-monitor-chat-id 配置的飞书群(与监控告警同键)。未配/发送失败 /// 仅落日志,不阻塞重连循环。重连通知是锦上添花,失败静默 async fn notify_monitor(ctx: &Ctx, text: &str) { let db = ctx.app.state::().db.clone(); let repo = SettingsRepo::new(&db); let chat_id = norm(repo.get("df-monitor-chat-id").await.ok().flatten()); if chat_id.is_empty() { feishu_log(&format!("[notify] 未配置 df-monitor-chat-id,仅落日志: {text}")); return; } let _ = send_markdown_card(&ctx.client, "", &chat_id, text).await; } async fn run_loop(ctx: Arc, client: &'static LarkClient) -> anyhow::Result<()> { let dispatcher = EventDispatcher::new("", ""); let ctx_msg = ctx.clone(); let ctx_ready = ctx.clone(); let ctx_card = ctx.clone(); // D3 看门狗与单次连接生命周期绑定:run_loop 退出时 abort,不逐重连累积 let watchdog = tauri::async_runtime::spawn(run_liveness_watchdog(ctx.clone())); // 新连接建立即视为存活起点(内部断线重连由 on_ready 再刷新) ctx.last_alive.store(now_secs(), Ordering::SeqCst); // Channel<'a> 借用 client;'static 由 start_if_enabled 在重连循环外一次性 Box::leak // 提供(D1:原在此处 leak,每次重连泄漏一份 LarkClient) let channel: Channel<'static> = Channel::builder(client, dispatcher) .policy(ChannelPolicy::default().require_mention(false)) // D3:SDK 内部每次断线重连成功都会回调,刷新活性防断流误报(SDK 读空闲超时 // 自愈真死连接后,断流计时从这里归零) .on_ready(move || { ctx_ready.last_alive.store(now_secs(), Ordering::SeqCst); }) .on_message(move |msg| { let ctx = ctx_msg.clone(); async move { // 异步处理:回调内绝不等 agent 跑完(单消息长任务会阻塞事件泵, // 2026-10-05 16:18 实证:rx 等待卡死后全部后续消息黑洞) tauri::async_runtime::spawn(async move { handle_message(ctx, msg).await; }); Ok(()) } }) .on_card_action(move |action| { let ctx = ctx_card.clone(); async move { tauri::async_runtime::spawn(async move { handle_card_action(ctx, action).await; }); Ok(()) } }) .build(); tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)"); let result = channel .start() .await .map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}")); watchdog.abort(); result?; Ok(()) } /// D3 探活常量:事件断流判定阈值。聊天渠道正常空闲也可能长时间无消息,阈值取长防把 /// 安静误判为假活 const LIVENESS_STALE_SECS: i64 = 600; /// D3 看门狗巡检间隔 const LIVENESS_TICK: std::time::Duration = std::time::Duration::from_secs(60); /// D3 探活看门狗:巡检"事件断流"(距最后事件/连接建立超阈值且无在途会话),命中落盘告警。 /// /// 只告警不强杀:Channel/ChannelBuilder 未暴露连接句柄、探活或关闭 API(SDK 0.3.12 勘察), /// 若强杀只能靠取消 channel.start()——SDK 的 ping 循环是独立 spawn 的任务且持有连接 /// 写半部,取消使其脱离管理,连接健康时永续 ping 形成僵尸连接、占飞书服务端连接配额, /// 比假活更危险。真死连接(TCP 断/对端静默)由 SDK 读空闲超时(2×ping 周期+5s)加内部 /// 自动重连自愈;本看门狗覆盖的是残余盲区:协议层活着但事件管道异常断流,告警给出 /// 排查起点(此前 bot 静默死无任何日志可查)。 async fn run_liveness_watchdog(ctx: Arc) { let mut warned = false; loop { tokio::time::sleep(LIVENESS_TICK).await; let elapsed = now_secs() - ctx.last_alive.load(Ordering::SeqCst); if elapsed <= LIVENESS_STALE_SECS { warned = false; continue; } // 有会话在途不告警(回复投递依赖当前连接);同一段断流只告警一次防刷屏 if ctx.inflight.load(Ordering::SeqCst) > 0 || warned { continue; } warned = true; tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活"); feishu_log(&format!( "探活告警: {elapsed}s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)" )); } } /// D3 在途会话计数 RAII:handle_message 从注册等待桥到投递完成的任意退出路径 /// (spawn 失败早退/投递完成)都保证计数回落 struct InflightGuard(Arc); impl Drop for InflightGuard { fn drop(&mut self) { self.0.fetch_sub(1, Ordering::SeqCst); } } fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// 命令解析前归一(30 半全角容错):全角 ASCII 变体与全角空格转半角。仅用于命令识别, /// 任务原文不归一(不动用户内容) fn normalize_halfwidth(s: &str) -> String { s.chars() .map(|c| match c { '\u{3000}' => ' ', c if ('\u{FF01}'..='\u{FF5E}').contains(&c) => { char::from_u32(c as u32 - 0xFEE0).unwrap_or(c) } _ => c, }) .collect() } /// 命令识别(30 大小写/附言容错):`/cmd` 或 `/cmd 附言` 皆命中,命令词小写化, /// 返回 (命令词, 附言)。非命令(无 / 前缀或空命令词)返回 None,落常规任务路径; /// 未知命令词由调用方落回常规路径 fn parse_command(text: &str) -> Option<(String, String)> { let t = normalize_halfwidth(text); let t = t.trim(); let rest = t.strip_prefix('/')?; let mut parts = rest.splitn(2, char::is_whitespace); let cmd = parts.next()?.to_ascii_lowercase(); if cmd.is_empty() { return None; } let tail = parts.next().unwrap_or("").trim().to_string(); Some((cmd, tail)) } /// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字, /// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」 const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)"; /// 图片入站大小上限(#958):base64 转换膨胀 4/3,vision 端点普遍接受 ≤20MB 内嵌图, /// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存 const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; /// 文件入站大小上限:文件不经 base64 内嵌(模型经 read_file 按需读盘),上限只防 /// 恶意超大件烧下载流量与磁盘,20MB 覆盖常规文档/报表/压缩包 const MAX_FILE_BYTES: usize = 20 * 1024 * 1024; /// 飞书消息资源下载带防线(图片/文件共用,对齐 u-claw fetchResource 经验):3 次指数 /// 退避(1s/4s)+单次 120s 超时——飞书资源接口偶发抖动,无重试时用户收到一次性失败; /// 超时防接口挂起拖死受理链。空响应与错误同待遇进重试(可能为服务端瞬时异常), /// 耗尽后报最后一次原因。resource_type=image/file,同一资源接口 async fn download_message_resource_with_retry( client: &LarkClient, message_id: &str, file_key: &str, resource_type: &str, label: &str, ) -> Result, String> { let mut last_err = String::from("下载失败: 未知错误"); for attempt in 0u32..3 { if attempt > 0 { tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await; } let dl = tokio::time::timeout( std::time::Duration::from_secs(120), client.channel_messaging().download_message_resource( message_id, file_key, resource_type, &RequestOption::default(), ), ) .await; match dl { Ok(Ok(r)) if !r.data.is_empty() => return Ok(r.data), Ok(Ok(_)) => last_err = format!("{label}内容为空"), Ok(Err(e)) => last_err = format!("下载失败: {e}"), Err(_) => last_err = "下载超时(120s)".into(), } } Err(last_err) } /// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。 /// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀), /// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。 /// 走 channel_messaging 便捷方法(与 reply/send 同风格),租户 token 由 client 自管。 /// media_type 由字节魔数判定(下载响应无可靠 MIME;飞书图床为 png/jpeg/webp/gif), /// 未知格式拒收——错误 media_type 会被 vision 端点 400,宁早拒不晚炸。 async fn download_feishu_image( client: &LarkClient, store: &crate::media_store::MediaStore, message_id: &str, file_key: &str, ) -> Result { let data = download_message_resource_with_retry(client, message_id, file_key, "image", "图片") .await?; if data.len() > MAX_IMAGE_BYTES { return Err(format!( "图片超过 10MB 上限(约 {}MB),请压缩后重发", data.len() / 1024 / 1024 )); } let media_type = sniff_image_mime(&data) .ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?; // 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析), // 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。 // 飞书 image 消息无原始文件名(None=纯 hash 名) let url = store.save(&data, media_type, None).await?; Ok(ContentPart::Image { url: Some(url), base64: None, media_type: Some(media_type.into()), alt: None, }) } /// 文件消息喂模型的任务语境:告知文件名与本机落盘路径,引导用 read_file 按需读取; /// 二进制/超大文件的读取局限如实交底,防模型谎称已完整处理。落盘前(无路径)给 /// 占位版,仅供空判与命令/审批判定,不喂模型不落卡 fn file_inbound_prompt(file_name: &str, path: Option<&str>) -> String { match path { Some(p) => format!( "(用户通过飞书发来文件「{file_name}」,已保存到本地路径 {p};请按用户需求用 read_file 工具读取该文件处理。二进制/超大文件可能无法完整读取,如实告知用户)" ), None => format!("(用户通过飞书发来文件「{file_name}」,保存中)"), } } /// 文件超限判定:超 20MB 上限时给可读错误话术(提示压缩/拆分),宁拒不落盘 fn file_size_over_limit_error(len: usize) -> Option { (len > MAX_FILE_BYTES).then(|| { format!( "文件超过 20MB 上限(约 {}MB),请压缩或拆分后重发", len / 1024 / 1024 ) }) } /// 文件展示名裁决:SDK 归一化 file_name 优先(保真,清洗首尾空白);缺失回退 /// `file_<内容 hash 前 8>`(与落盘 hash 命名同源,可对账辨认) fn file_display_name(raw: &str, bytes: &[u8]) -> String { let raw = raw.trim(); if raw.is_empty() { format!("file_{}", &crate::media_store::content_hash(bytes)[..8]) } else { raw.to_string() } } /// 下载飞书文件消息资源并落盘,返回 (展示名, 本机绝对路径)。 /// 与图片同一资源接口(resource_type=file)与下载防线;落盘走 media_store 通用入口 /// (hash 去重+OSS 双写+失败降级,原文件名缀后保目录可读)。返回本机路径喂模型 /// read_file(OSS https 引用对本机读取无意义,不作返回);超 20MB 拒收回可读错误 async fn download_feishu_file( client: &LarkClient, store: &crate::media_store::MediaStore, message_id: &str, res: &larksuite_oapi_sdk_rs::channel::ChannelResource, ) -> Result<(String, String), String> { let data = download_message_resource_with_retry(client, message_id, &res.file_key, "file", "文件") .await?; if let Some(err) = file_size_over_limit_error(data.len()) { return Err(err); } let display_name = file_display_name(&res.file_name, &data); let original = res.file_name.trim(); let (_, path) = store .save_with_path( &data, "application/octet-stream", (!original.is_empty()).then_some(original), ) .await?; Ok((display_name, path.to_string_lossy().into_owned())) } /// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式) async fn read_media_oss_config( db: &df_storage::db::Database, ) -> Option { crate::media_store::read_oss_config(db).await } /// 图片格式魔数判定(#958):PNG/JPEG/GIF/WEBP 四态,与飞书图床常见格式对齐 fn sniff_image_mime(b: &[u8]) -> Option<&'static str> { if b.starts_with(&[0x89, b'P', b'N', b'G']) { Some("image/png") } else if b.starts_with(&[0xFF, 0xD8, 0xFF]) { Some("image/jpeg") } else if b.starts_with(b"GIF8") { Some("image/gif") } else if b.len() > 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP" { Some("image/webp") } else { None } } /// 防抖窗口(拍板 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>, /// 已追加条数(不含首条) appended: usize, } impl DebounceEntry { /// 追加一条进窗:文本空行拼接、图片片到达序合并、计数累加 fn append(&mut self, text: &str, parts: Option>) { 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>, next: Option>, ) -> Option> { 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>, placeholder_id: Option, } /// 连发消息防抖聚合(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>, ) -> Option { { 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, msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage, ) { // D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳 ctx.last_alive.store(now_secs(), Ordering::SeqCst); // 群聊响应门控对齐 u-claw 模式(feishu_events.go mentionedBot 四条):①@自己响应 // ②@所有人响应 ③@别的bot不响应 ④无mention静默旁观;私聊恒响应。SDK 的 // mentioned_bot/mention_all 已承载①②③的精确判定。门控必须在类型分流之前—— // 分流段对语音等未支持类型会回提示卡,门控放后面时未艾特的群消息也会得到回复 // (2026-10-15 用户实测「群里没艾特也处理」根因)。 if msg.chat_type == "group" && !msg.mentioned_bot && !msg.mention_all { return; } // 类型分流(#958 图片入站,文件入站同源扩展):text 原样;image 走下载装配(vision // ContentPart);file 走下载落盘(read_file 本机路径);语音等其余类型维持提示卡。 // image/file 消息 SDK 归一化已把 file_key/file_name 放入 msg.resources(ChannelResource), // 无需自行解析 content JSON。 let (image_key, file_res) = if msg.message_type == "image" { match msg.resources.iter().find(|r| r.resource_type == "image") { Some(r) => (Some(r.file_key.clone()), None), None => { let _ = reply_markdown_card( &ctx.client, &msg.message_id, "⚠️ 图片消息缺少 image_key,无法处理。", ) .await; return; } } } else if msg.message_type == "file" { match msg.resources.iter().find(|r| r.resource_type == "file") { Some(r) => (None, Some(r.clone())), None => { let _ = reply_markdown_card( &ctx.client, &msg.message_id, "⚠️ 文件消息缺少 file_key,无法处理。", ) .await; return; } } } else if msg.message_type != "text" { // #25 非文本消息静默丢修复:回提示卡,不再零反馈。文本/图片/文件已支持, // 余下语音等类型如实告知规划中 let _ = reply_markdown_card( &ctx.client, &msg.message_id, "📎 暂支持文本、图片与文件消息,语音等其他类型规划中。", ) .await; return; } else { (None, None) }; // 附件消息不带文字(飞书 image/file 均为独立消息类型),msg.text 的图链/文件标签 // 对模型无意义,不喂:图片喂固定引导词交代任务语境;文件语境需落盘路径,先预填 // 文件名占位版(命令/审批判定恒不命中),下载点覆写为含路径终版 let mut text = if image_key.is_some() { IMAGE_INBOUND_PROMPT.to_string() } else if let Some(res) = &file_res { file_inbound_prompt(res.file_name.trim(), None) } else { let text = msg.text.clone().unwrap_or_default().trim().to_string(); // 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本 strip_mention_placeholders(&text) }; if text.is_empty() { return; } let chat_id = msg.chat_id.clone(); let message_id = msg.message_id.clone(); let open_id = msg .sender .user_id .as_ref() .and_then(|u| u.open_id.clone()) .unwrap_or_default(); // 18 探活口径修正:inflight 计数从此(白名单通过即受理)覆盖到投递完成——原计数点 // 在 spawn 前,占位卡 HTTP+per-chat 锁排队(可等上一任务全程)不在覆盖内,该窗口 // 连接假活会误报「无在途会话」。全部早退路径(占位卡失败/注入旁路/常规路径后续 // return)经 RAII 回落,纯过滤段(@/空文本/白名单拒)在计数点之前不受影响。 ctx.inflight.fetch_add(1, Ordering::SeqCst); let _inflight_guard = InflightGuard(ctx.inflight.clone()); // 白名单(D2 热加载):空=放行;非空且不在内 → 提示并附自己的 open_id(首启自助回填, // 免翻日志)。每消息现读 KV,设置面板改白名单即生效免重启 // 2026-10-08 配置引导优化:提示改为分步自助引导(原仅附 open_id,用户不知填哪) let whitelist = read_whitelist(&ctx).await; if !whitelist.is_empty() && !whitelist.iter().any(|w| w == &open_id) { let hint = format!( "⛔ 你不在白名单内,无法使用。\n\n**你的 open_id**:\n`{open_id}`\n\n**开通步骤**:\n1. 复制上方 open_id\n2. 桌面端「设置 → 飞书集成 → 白名单」粘贴(多个用逗号分隔)\n3. 保存后重发消息即可(免重启)" ); // 用 markdown 卡片(非 reply_message 纯文本),让 **粗体**/`code`/列表 正确渲染 let _ = reply_markdown_card(&ctx.client, &message_id, &hint).await; return; } // 图片入站(#958):下载消息资源→落盘→引用片。置于白名单后(未授权不下载, // 不替白名单外用户烧流量)、占位发票前(失败早退,无占位卡需原地更新) let parts = match &image_key { Some(key) => { let state = ctx.app.state::(); let oss_cfg = read_media_oss_config(&state.db).await; let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg); match download_feishu_image(&ctx.client, &store, &message_id, key).await { Ok(part) => Some(vec![part]), Err(e) => { feishu_log(&format!("图片下载失败 chat={chat_id}: {e}")); let _ = reply_markdown_card( &ctx.client, &message_id, &format!("⚠️ 图片下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"), ) .await; return; } } } None => None, }; // 文件入站:下载消息资源→落盘拿本机路径→覆写任务语境终版(含路径,模型 read_file // 按需读取)。与图片同段白名单后下载(未授权不烧流量)、占位发票前早退(失败回 // 可读错误卡,超 20MB 话术在下载错误内) if let Some(res) = &file_res { let state = ctx.app.state::(); let oss_cfg = read_media_oss_config(&state.db).await; let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg); match download_feishu_file(&ctx.client, &store, &message_id, res).await { Ok((name, path)) => { text = file_inbound_prompt(&name, Some(&path)); } Err(e) => { feishu_log(&format!("文件下载失败 chat={chat_id}: {e}")); let _ = reply_markdown_card( &ctx.client, &message_id, &format!("⚠️ 文件下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"), ) .await; return; } } } // 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。 // 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充 // 指令注入任务;未知命令词落回常规任务路径,零行为变更 if let Some((cmd, _tail)) = parse_command(&text) { match cmd.as_str() { // /new 重置该飞书会话映射(下次消息开新 conv)。附言容错忽略不烧 LLM; // 旧 conv 任务在跑时重置只换绑定不杀任务,明示去向与出路(30) "new" => { let prev = ctx.chat_map.lock().await.get(&chat_id).cloned(); ctx.chat_map.lock().await.remove(&chat_id); // 旧 conv 滚动汇总卡句柄一并清:绑定已换,推送不再续旧卡防上下文串卡 if let Some(m) = ROLLING_CARDS.get() { if let Ok(mut h) = m.lock() { h.remove(&chat_id); } } let db = ctx.app.state::().db.clone(); let snapshot = ctx.chat_map.lock().await.clone(); save_chat_map(&db, &snapshot).await; let state = ctx.app.state::(); let busy_note = match prev { Some(cid) if state.conv_states.is_active(&cid) => { "\n⚠️ 原会话任务仍在后台执行,本群不再接收其推送;如需停止请发 /cancel。" } _ => "", }; reply_message( &ctx.client, &message_id, &chat_id, &format!("✅ 已重置会话{busy_note}"), ) .await; return; } // #21 /status 查询当前会话任务状态(话术曾引用不存在的命令,用户发 /status 被当任务 // 烧一轮 LLM)。只读不 spawn:查 conv 绑定与活跃态给进度摘要。 "status" => { let state = ctx.app.state::(); let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned(); let reply = match conv_id { None => "📭 当前无绑定会话,发送消息即自动创建。".to_string(), Some(cid) => { let short = &cid[..8.min(cid.len())]; match state.conv_states.get(&cid) { s if s.is_active() => format!("🚀 会话 `{short}` 任务执行中,完成后自动推送结果;可发消息注入补充指令(下一轮生效)。"), crate::commands::ai::agentic::conv_state::ConvState::Compressed => { format!("⏳ 会话 `{short}` 压缩暂存中,稍后自动续跑。") } crate::commands::ai::agentic::conv_state::ConvState::Stopping => { format!("⏹ 会话 `{short}` 正在停止。") } crate::commands::ai::agentic::conv_state::ConvState::Error => { format!("❌ 会话 `{short}` 上次任务出错,重发消息即可重试。") } _ => "✅ 会话空闲,无进行中任务。".to_string(), } } }; reply_message(&ctx.client, &message_id, &chat_id, &reply).await; return; } // 24 取消通路:飞书侧此前零 stop 通道,跑偏任务只能干等。对接引擎停止原语 // ai_stop_loop(置 stop_flag,loop 于轮边界收尾),并旁路唤醒完成订阅任务收口; // 无在途任务如实回告,不误报已停止 "cancel" => { let state = ctx.app.state::(); let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned(); let reply = match conv_id { Some(cid) if state.conv_states.is_active(&cid) => { feishu_log(&format!("/cancel 停止 conv={cid} chat={chat_id}")); match crate::commands::ai::ai_stop_loop(ctx.app.clone(), state, cid.clone()) .await { Ok(_) => { if let Some(tx) = cancel_watchers().lock().await.remove(&cid) { let _ = tx.send(()); } "🛑 已发送停止指令,任务收尾中。".to_string() } Err(e) => format!("⚠️ 停止指令未生效:{}", truncate(&e, 120)), } } _ => "当前无在途任务。".to_string(), }; reply_message(&ctx.client, &message_id, &chat_id, &reply).await; return; } _ => {} } } // 24 审批决议通路:该会话有挂起审批时,「批准/拒绝」直调审批原语不入对话链路 // (决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。决议取该会话最新 // 一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更 if text == "批准" || text == "拒绝" { let state = ctx.app.state::(); let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned(); let pending = match conv_id { Some(ref cid) => latest_pending_approval(&state, cid).await, 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 拿结果摘要 let result_rx = state.ai_event_bus.subscribe(); let reply = match crate::commands::ai::ai_approve(ctx.app.clone(), state, tc_id.clone(), approved).await { // 幂等已处理(重复决议/陈旧挂起):ai_approve 返 Ok("已处理(...)"),不误报 Ok(msg) if msg.starts_with("已处理") => { format!("ℹ️ 该审批已处理过({msg}),无需重复操作。") } Ok(_) if approved => "✅ 已批准,任务继续执行。".to_string(), Ok(_) => "🚫 已拒绝,该工具调用终止,任务按拒绝结果继续。".to_string(), Err(e) => format!("⚠️ 审批处理失败:{}", truncate(&e, 160)), }; // 多条挂起提示:本会话仍有其他待审批时追加提醒,防用户以为已全批 // (state 已被 ai_approve move,重新取句柄) let remaining = match conv_id { Some(ref cid) => { let st = ctx.app.state::(); count_pending_approvals(&st, cid).await } None => 0, }; let reply = if remaining > 0 { format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「批准」或「拒绝」。") } else { reply }; reply_message(&ctx.client, &message_id, &chat_id, &reply).await; // 仅批准且决议成功时订阅执行结果(拒绝不执行工具,无结果可言;已处理幂等跳过) if approved && !reply.starts_with('⚠') && !reply.starts_with('ℹ') { spawn_approval_result_watcher(ctx.clone(), chat_id.clone(), tc_id, result_rx); } return; } } // ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ── // 排队语义的痛点:per-chat 锁把补充指令排到 agent 完成后才处理,长任务期间纠偏无效。 // 注入语义:conv 活跃(Generating/Compressed)时直接写 per_conv.messages,loop 下一轮 // build_for_request 自然带上;引擎层能力(chat.rs inject_runtime_message),飞书/桌面同源。 // 非活跃(Idle/Error)→ Err → 落回下方常规路径,零行为变更。 { let state = ctx.app.state::(); let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned(); if let Some(cid) = conv_id_now { // 附件消息(图片/文件)不注入,走与图片相同的后续链:inject_runtime_message // 只收文本,图片注入则图被丢、模型只见引导词空跑;文件作为独立新任务语义 // 同样应过受理/防抖/发票链,conv 占用时受理守卫如实回告,不并入运行中任务 if image_key.is_none() && file_res.is_none() && state.conv_states.is_active(&cid) { match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await { Ok(true) => { let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await; return; } _ => { /* 注入失败/竞态退出:落回常规路径(spawn 会因 can_accept_request 被拦,收敛安全) */ } } } } } // chat_id → conv_id 映射(内存命中优先,未命中新建) feishu_log(&format!("收到消息 chat={chat_id} open_id={open_id} text={}", truncate(&text, 80))); // ── 连续消息防抖聚合(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; locks.entry(chat_id.clone()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone() }; let _chat_guard = chat_lock.lock().await; let conv_id = { let mut map = ctx.chat_map.lock().await; map.entry(chat_id.clone()).or_insert_with(new_id).clone() }; feishu_log(&format!("spawn 会话 conv={conv_id}")); // D3 在途计数已前移至 handle_message 白名单通过处(探活口径修正,18),此处不再重复计数 let state = ctx.app.state::(); // #20 受理守卫:发票(受理卡)前先过引擎读侧闸。不可启动(前任务占用/压缩暂存/停止中) // 不发票、不 spawn、不注册桥与流式任务,堵「受理卡已发后续零通知」的伪受理断层。 // 959 必达改造(对齐 u-claw 收件箱续投):拒绝路径改落入待发箱(queue 尽快项), // 前序任务完成调度器自动派发+结果回推本 chat——重试责任不在用户,不再让用户重发。 // 入队失败(写库异常等)降级回原占用话术,消息不丢语义不倒退 if !state.conv_states.can_accept_request(&conv_id) { feishu_log(&format!("受理守卫拒绝 chat={chat_id} conv={conv_id}")); match crate::commands::ai::outbox::enqueue_internal( &ctx.app, &state, text.clone(), None, // 尽快队列项:前序任务空闲即发 None, None, conv_id.clone(), ) .await { Ok(_) => { feishu_log(&format!("已入待发箱 chat={chat_id} conv={conv_id}")); deliver_result( &ctx.client, &placeholder_id, &message_id, &chat_id, "📥 已登记待发:前序任务完成后将自动发出,无需重发(期间可继续追加,均按补充登记)。", ) .await; } Err(e) => { feishu_log(&format!("待发箱入队失败 chat={chat_id}: {e}")); deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, QUEUE_BUSY_MSG) .await; } } return; } // 通知桥注册(#14):oneshot 接收端有意 drop——两段式下终版走事件广播 // (deliver_completion_report_if_waited 的 AiAgentFinished publish),桥条目存在的 // 唯一作用是 has_agent_completion_waiter 为真(触发引擎层取报告+广播);rx.drop 触发 // tx.send 失败静默,无泄漏(bridge 条目 send 后即移除)。 let (tx, _rx) = tokio::sync::oneshot::channel::(); state.register_agent_completion(conv_id.clone(), tx); // 过程可见性(13a):预览任务订阅既有 ai_event_bus 拿本 conv 的增量(delta/轮次/工具/ // 进度)节流刷新占位卡;受理卡与终版由完成订阅任务经帧通道投递。本任务为占位卡唯一 // 写者——中间态与终版同任务串行,无迟到中间态覆盖终版的竞态。spawn 前订阅防早期增量丢失 let (frame_tx, frame_rx) = tokio::sync::mpsc::channel::(8); let delta_rx = state.ai_event_bus.subscribe(); let stream_task = tauri::async_runtime::spawn(run_stream_refresher( ctx.clone(), conv_id.clone(), placeholder_id.clone(), message_id.clone(), chat_id.clone(), delta_rx, frame_rx, )); let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text) match crate::commands::ai::commands::chat::spawn_agentic_session( &ctx.app, &state, text, None, None, None, Some(conv_id.clone()), parts, // #958 图片入站:图片消息带 vision 片,文本消息 None;vision 能力判定/降级在 spawn 内 None, false, // 不劫持用户活跃面板(编排节点同款语义) // #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源 Some(df_ai::provider::MessageSource { channel: "feishu".into(), message_id: message_id.clone(), chat_id: Some(chat_id.clone()), sender: if open_id.is_empty() { None } else { Some(open_id.clone()) }, }), ) .await { Ok(_) => { let db = ctx.app.state::().db.clone(); let snapshot = ctx.chat_map.lock().await.clone(); save_chat_map(&db, &snapshot).await; } Err(e) => { // 启动失败:通知桥条目清掉防死条目(编排节点同款) state.notify_agent_completion(&conv_id, String::new()); // 关帧通道让预览任务退出(此路无任何帧产出,卡片未被写过), // 待其退出后再走失败投递,保卡片写入单点次序 drop(frame_tx); let _ = stream_task.await; feishu_log(&format!("spawn 失败 conv={conv_id}: {e}")); // 18 渠道错误话术透传:并发护栏拒绝(生成中)≠真失败——固定「启动失败」话术 // 吞掉真实原因,用户误判 bot 坏了重试白撞。按引擎层错误文案分类: // 生成中→明示任务进行中不会丢;其余→附截断原始原因(不吞因)。 let user_msg = if e.contains("正在生成") || e.contains("生成中") { // #26 占用期话术改实况:委托制无真排队,禁承诺「任务完成后处理」 QUEUE_BUSY_MSG.to_string() } else { format!("❌ 会话启动失败:{},请稍后重试", truncate(&e, 120)) }; deliver_result( &ctx.client, &placeholder_id, &message_id, &chat_id, &user_msg, ) .await; return; } } // ── #14 任务式委托(2026-10-06):受理即返回,完成走事件驱动 ── // spawn Ok = 任务已受理并在后台运行(loop 独立 task,30min 超时底座既有,断点续跑兜底)。 // 原同步等待段(timeout 1800s oneshot)整体移除:发「走查代码」级重任务不再挂住本 // 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher // 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited // 统一发出,单一事实源),原地 edit_card 占位卡为结果。 // 卡片所有权(13a 修订):占位卡唯一写者是预览任务(run_stream_refresher),受理卡与 // 终版都由本完成订阅任务经帧通道交给它按序落卡;预览任务内部串行,消除「受理卡与终版 // 两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。13a 前独立预览任务在受理 // 即被关停(drop 帧通道),心跳/阶段进展形同虚设;此处交回帧通道驱动使其真正落到卡片。 let ack = format!("🚀 已受理,后台执行中\n任务:{task_brief}"); feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}")); // 完成订阅任务:独立于消息处理生命周期,直到 AiAgentFinished 广播到达投递终版。 // (spawn Err 路径在上方已 return,永不到达此处——watcher 依赖引擎层正常完成广播) // #20 附带:inflight 生命周期跟任务走,guard 移交 watcher 持有。原 guard 随本函数 // 返回即 drop,受理后任务还在后台跑计数却归零,探活误报窗口重开;任务终局(watcher // 各退出路径)guard drop 自然回落。与本函数前置计数的短暂重叠对看门狗无影响(判据 >0) let inflight_guard = InflightGuard(ctx.inflight.clone()); // 24 取消收口:注册本 conv 的取消发送端,/cancel 触发后旁路唤醒 watcher 投终版 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); cancel_watchers().lock().await.insert(conv_id.clone(), cancel_tx); let conv_watched = conv_id.clone(); let watched_chat = chat_id.clone(); tauri::async_runtime::spawn(async move { run_completion_watcher( ctx, conv_id, watched_chat, ack, inflight_guard, cancel_rx, frame_tx, stream_task, ) .await; // watcher 任意路径退出都摘除取消句柄,防陈旧条目驻留(后续 /cancel 空发无害) cancel_watchers().lock().await.remove(&conv_watched); }); } /// #27 快任务宽限窗:受理卡发出后的事件驱动判定窗(原入场 elapsed>=3s 恒假死分支)。 /// 窗内到达的完成事件由广播 rx 缓冲零丢失;窗结束时以「conv 非活跃 + 边界内有产出」 /// 判定秒级任务已完成。8s 覆盖秒级任务;长任务仅推迟主循环进入,不影响终版到达时刻 const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8); /// 卡片帧:占位卡唯一写者(预览任务)与裁决者(完成订阅任务)之间的投递契约。 /// terminal=true 的帧(终版/取消/失败)写入后预览任务收工退出,保证卡片最后写入即终版。 struct CardFrame { text: String, terminal: bool, } // ── 出站文件回传(#958,u-claw replyLocalFiles 同模式) ── // 约定:AI 报告中写 `[交付] <绝对路径>` 行,渠道自动把文件上传飞书回发—— // 图片走图片消息、其余走文件消息(飞书原生附件体验,免用户去桌面端翻)。 // 正文中提及路径但无标记行=叙述,不发送(防止口述路径误上传)。 /// 提取报告中全部 `[交付] <路径>` 行的路径(纯函数)。容错:全半角冒号后空格可选, /// 路径含空格整行取(行内唯一路径假设,与 AI 约定一致) fn extract_delivery_paths(text: &str) -> Vec { text.lines() .filter_map(|line| { let line = line.trim(); let rest = line.strip_prefix("[交付]")?; let path = rest.trim().trim_start_matches(':').trim_start_matches(':').trim(); (!path.is_empty()).then(|| path.to_string()) }) .collect() } /// 敏感文件拦截(防御纵深,对齐 u-claw replyBlocked):名字命中凭据/密钥特征即拒, /// AI 被诱导交付敏感文件时兜底。返回 Some(原因)=拦截 fn delivery_blocked(path: &str) -> Option<&'static str> { let lower = path.to_lowercase(); const SENSITIVE: [&str; 8] = [ ".env", "id_rsa", "credential", "password", "secret", "token", "api_key", "apikey", ]; SENSITIVE .iter() .find(|k| lower.contains(**k)) .map(|_| "敏感文件(凭据/密钥特征)") } /// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举) fn feishu_file_type(ext: &str) -> &'static str { match ext { "pdf" => "pdf", "xls" | "xlsx" | "csv" => "xls", "doc" | "docx" | "rtf" => "doc", "ppt" | "pptx" => "ppt", "mp4" | "mov" | "avi" | "mkv" => "mp4", "mp3" | "wav" | "m4a" | "aac" | "flac" => "mp3", _ => "stream", } } /// 上传回传全部交付文件。单文件失败只提示不中断(部分成功优于整体失败); /// 文件不存在静默跳过并记日志(过滤残余误匹配,"写了路径文件不在"经日志可查) async fn reply_deliveries(client: &LarkClient, chat_id: &str, text: &str) { for path in extract_delivery_paths(text) { if let Some(why) = delivery_blocked(&path) { feishu_log(&format!("回传跳过({why}): {path}")); send_text(client, chat_id, &format!("⚠️ 已拦截交付请求:{path}({why})")).await; continue; } let p = std::path::Path::new(&path); match tokio::fs::metadata(p).await { Ok(m) if m.is_file() => {} _ => { feishu_log(&format!("回传跳过(文件不存在或为目录): {path}")); continue; } } let bytes = match tokio::fs::read(p).await { Ok(b) => b, Err(e) => { feishu_log(&format!("回传读取失败({path}): {e}")); continue; } }; let opt = &RequestOption::default(); let ext = p .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); let is_image = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp"); let sent: Result<(), String> = if is_image { // 图片:上传 image → image 消息直发(直发不做 reply,占位卡已是回复链, // 附件直发即可,避免嵌套回复噪声) match client.im().image.create("message", bytes, opt).await { Ok(resp) => match resp.data.as_ref().and_then(|d| d.image_key.clone()) { Some(key) => { let input = SendInput { msg_type: Some("image".into()), image_key: Some(key), chat_id: Some(chat_id.to_string()), ..Default::default() }; client .channel_messaging() .send(&input, opt) .await .map(|_| ()) .map_err(|e| format!("发送失败: {e}")) } None => Err("响应缺 image_key".into()), }, Err(e) => Err(format!("图片上传失败: {e}")), } } else { // 文件:上传 file → file 消息直发(飞书好友互发文件同款体验) let name = p .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "file".into()); match client .im() .file .create(feishu_file_type(&ext), &name, None, bytes, opt) .await { Ok(resp) => match resp.data.as_ref().and_then(|d| d.file_key.clone()) { Some(key) => { let input = SendInput { msg_type: Some("file".into()), file_key: Some(key), chat_id: Some(chat_id.to_string()), ..Default::default() }; client .channel_messaging() .send(&input, opt) .await .map(|_| ()) .map_err(|e| format!("发送失败: {e}")) } None => Err("响应缺 file_key".into()), }, Err(e) => Err(format!("文件上传失败: {e}")), } }; if let Err(e) = sent { feishu_log(&format!("回传失败({path}): {e}")); let base = path.rsplit(['/', '\\']).next().unwrap_or(path.as_str()); send_text(client, chat_id, &format!("⚠️ 文件回传失败:{base}({e})")).await; } else { feishu_log(&format!("回传完成: {path}")); } } } /// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv) /// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段 /// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。 /// /// 超时语义:35min(> loop 30min 上限)首超时回查边界内产出,无果投降级提示但不退出 /// (任务后台不丢,晚到完成事件仍送达);二次超时真弃守(#23:两轮均无事件且无边界内 /// 产出,认任务失联,停止重刷)。 async fn run_completion_watcher( ctx: Arc, conv_id: String, chat_id: String, ack: String, // #20:计数生命周期跟任务走,guard 持有至 watcher 退出 _inflight: InflightGuard, // 24 取消收口:/cancel 旁路唤醒(引擎停止路径只 emit AiCompleted,本任务消费不到) mut cancel_rx: tokio::sync::oneshot::Receiver<()>, // 13a:占位卡写权限交预览任务,本任务所有落卡经此投递(terminal 标记终版) frame_tx: tokio::sync::mpsc::Sender, // 预览任务句柄:投终版后 await 其退出,保证落卡完成后才释放 inflight stream_task: tauri::async_runtime::JoinHandle<()>, ) { let state = ctx.app.state::(); let mut rx = state.ai_event_bus.subscribe(); // #22 受理时刻(Unix 毫秒):快任务兜底与超时回查只认此后落库的 assistant 消息, // 防把上一任务旧回答冒充本次结果(无 timestamp 的老消息视为不在边界内) let accepted_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0); // 落受理卡(经预览任务原地更新占位卡,未收到终版前预览任务持续刷新过程进展) let _ = frame_tx.send(CardFrame { text: ack, terminal: false }).await; // #27 快任务兜底:受理卡发出后开宽限窗再判定,事件驱动替代恒假的时间比较。 // 秒级任务的完成事件可能先于本订阅已广播完(错过),窗结束时 conv 已归非活跃且 // 边界内有产出,即判快任务已完成,直投终版 tokio::time::sleep(FAST_TASK_GRACE).await; if !state.conv_states.is_active(&conv_id) { if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await { if !t.trim().is_empty() { feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len())); let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await; let _ = stream_task.await; return; } } } // 事件等待循环:首超时投降级提示不退出;二次超时真弃守(#23) let timeout = std::time::Duration::from_secs(2100); let mut timeouts = 0usize; loop { let frame = tokio::select! { // 24 取消收口:/cancel 旁路唤醒,投「已停止」终版后退出,不再等引擎事件 _ = &mut cancel_rx => { feishu_log(&format!("watcher 收到取消指令 conv={conv_id}")); CardFrame { text: "🛑 任务已停止。可发 /status 查看会话状态,或直接发新任务。".to_string(), terminal: true } } item = rx.recv() => match item { Ok(v) => { if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) { continue; } match v.get("type").and_then(|t| t.as_str()) { // #23 防任务泄漏:引擎错误路径只发 AiError 不发 AiAgentFinished(引擎侧 // 补广播在 chat.rs 域,本文件域禁触),渠道侧消费 AiError 视同终局, // watcher 即刻收口,不再空挂到双超时弃守 Some("AiError") => { let err = v.get("error").and_then(|s| s.as_str()).unwrap_or("未知错误"); feishu_log(&format!("watcher 收到 AiError conv={conv_id}")); CardFrame { text: format!("❌ 任务执行失败:{},可稍后重试,或发 /status 查看会话状态。", truncate(err, 300)), terminal: true, } } Some("AiAgentFinished") => { // 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造) CardFrame { text: v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string(), terminal: true, } } _ => continue, } } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => return, }, _ = tokio::time::sleep(timeout) => { timeouts += 1; // 二次超时弃守:两轮 35min 均无事件且无边界内产出,认任务失联,停止重刷 if timeouts >= 2 { feishu_log(&format!("watcher 二次超时弃守 conv={conv_id}")); CardFrame { text: "⏰ 任务超 70 分钟无任何结果(已停止等待;引擎仍在后台运行则结果稍后可在桌面端查看;可发 /status 查询)。".to_string(), terminal: true, } } else { // 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版, // 无果投降级提示并继续等待(晚到事件仍送达) match last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await { Some(t) if !t.trim().is_empty() => CardFrame { text: t, terminal: true }, _ => { let _ = frame_tx.send(CardFrame { text: "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string(), terminal: false, }).await; continue; } } } } }; feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len())); // #958 出站回传(u-claw replyLocalFiles 同模式):报告内 [交付] 标记行的本地文件 // 自动上传飞书(图片→图片消息/其余→文件消息),用户在飞书直接收原生附件。 // 终版文本中的标记行本身保留(AI 写路径也是叙述的一部分,卡内可见) reply_deliveries(&ctx.client, &chat_id, &frame.text).await; let _ = frame_tx.send(frame).await; let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight return; } } /// 24 审批可见性:渠道生命周期订阅任务。消费 ai_event_bus 的 AiApprovalRequired,反查 /// conv 绑定的飞书 chat 推卡提示(回复「批准」或「拒绝」);无绑定(纯桌面会话)不推, /// 桌面 ApprovalOverlay 既有通路处理。审批挂起时流式卡静止无解释,本推送补齐可见性, /// 决议闭环由 handle_message 的「批准/拒绝」拦截承接 async fn run_approval_notifier(ctx: Arc) { let state = ctx.app.state::(); 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}。回复「批准」或「拒绝」继续") }; 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, } } } /// 卡片按钮回调(2026-10-08 审批卡按钮化):解析按钮 value 的 {approval_id, approved}, /// 经 ai_approve 原语决议。与文本「批准/拒绝」通路同原语,双通道并存(按钮优先, /// 文本作降级)。回调在独立 spawn 内执行,不阻塞事件泵。 async fn handle_card_action( ctx: Arc, action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction, ) { // 按钮约定的 value 形态:{"approval_id": "", "approved": true|false} let approval_id = action .action_value .get("approval_id") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); if approval_id.is_empty() { tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略"); return; } let approved = action .action_value .get("approved") .and_then(|v| v.as_bool()) .unwrap_or(false); // chat_id 兜底:SDK 归一化未带 open_chat_id 时,从 approval_id 反查 conv → chat_map // 反查绑定 chat,防 send_markdown_card(client, "", "", ...) 发到空 chat 静默失败 let mut chat_id = action.open_chat_id.clone().unwrap_or_default(); 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() { feishu_log(&format!("卡片回调 chat_id 缺失,反查补齐 chat={chat_id}")); } } } if chat_id.is_empty() { // 仍拿不到落点:决议照常执行(不能因投递无门而丢决议),但记日志便于排查 tracing::warn!("[feishu] 卡片回调 chat_id 缺失且反查失败,回执无法投递 tc={approval_id}"); feishu_log(&format!("卡片回调 chat_id 缺失且反查失败 tc={approval_id}")); } // 白名单校验(与 handle_message 同口径):卡片按钮回调此前无校验,非白名单用户 // 若能看到审批卡(被转发/群内他人),点按钮可直接批准工具执行,白名单形同虚设。 // 取回调发起者 open_id 校验;取不到(空)时保守拒绝(宁拒勿放) let operator_open_id = action.operator_open_id.clone().unwrap_or_default(); let whitelist = read_whitelist(&ctx).await; if !whitelist.is_empty() && !whitelist.iter().any(|w| w == &operator_open_id) { feishu_log(&format!( "卡片回调白名单拒绝 chat={chat_id} operator={operator_open_id}" )); if !chat_id.is_empty() { let _ = send_markdown_card( &ctx.client, "", &chat_id, "⛔ 你不在白名单内,无权审批。请在桌面端「设置 → 飞书集成 → 白名单」添加你的 open_id。", ) .await; } return; } feishu_log(&format!( "审批按钮回调 chat={chat_id} tc={approval_id} approved={approved}" )); let state = ctx.app.state::(); // 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过) let result_rx = state.ai_event_bus.subscribe(); // 结果回执发新消息(不依赖 NormalizedCardAction 的 open_message_id 字段, // 该字段在长连接归一化结构中的存在性未经 SDK 源码证实;open_chat_id 已由网关实现验证) let reply = match crate::commands::ai::ai_approve( ctx.app.clone(), state, approval_id.clone(), approved, ) .await { // 幂等已处理(重复点击/陈旧卡片):ai_approve 返 Ok("已处理(...)"),不能误报「已批准」 Ok(msg) if msg.starts_with("已处理") => { format!("ℹ️ 该审批已处理过({msg}),无需重复操作。") } Ok(_) if approved => "✅ 已批准,任务继续执行。".to_string(), Ok(_) => "🚫 已拒绝,该工具调用终止,任务按拒绝结果继续。".to_string(), Err(e) => format!("⚠️ 审批处理失败:{}", truncate(&e, 160)), }; let _ = send_markdown_card(&ctx.client, "", &chat_id, &reply).await; // 仅批准且决议成功时订阅执行结果(拒绝不执行工具,无结果可言); // 已处理幂等路径也跳过(工具早已执行完,结果无从取) if approved && !reply.starts_with('⚠') && !reply.starts_with('ℹ') { spawn_approval_result_watcher(ctx.clone(), chat_id.clone(), approval_id, result_rx); } } /// 审批决议后执行结果 watcher(2026-10-08):订阅 ai_event_bus 的 AiToolCallCompleted /// (匹配 tool_call_id),拿 result 摘要回推 chat。原决议后只回「已批准」,用户不知工具 /// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时静默放弃)。 /// /// 订阅须在 ai_approve 调用前完成——publish_event 在无订阅者时跳过序列化,晚订阅必丢事件。 fn spawn_approval_result_watcher( ctx: Arc, chat_id: String, tool_call_id: String, mut rx: tokio::sync::broadcast::Receiver, ) { tauri::async_runtime::spawn(async move { let timeout = std::time::Duration::from_secs(60); let deadline = tokio::time::Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}")); return; } match tokio::time::timeout(remaining, rx.recv()).await { Ok(Ok(v)) => { if v.get("type").and_then(|t| t.as_str()) != Some("AiToolCallCompleted") { continue; } if v.get("id").and_then(|i| i.as_str()) != Some(tool_call_id.as_str()) { continue; } // result 是 serde_json::Value,提取文本摘要(字符串直用,对象转 JSON 截断) let summary = match v.get("result") { Some(serde_json::Value::String(s)) => truncate(s, 500), Some(other) => truncate(&other.to_string(), 500), None => "(无输出)".to_string(), }; let text = format!("📋 工具执行结果:\n{summary}"); let _ = send_markdown_card(&ctx.client, "", &chat_id, &text).await; feishu_log(&format!("[approval-result] 已回推结果 tc={tool_call_id}")); return; } Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return, Err(_) => { feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}")); return; } } } }); } /// 审批按钮卡片(2026-10-08):标题 + 详情 + 「同意/拒绝」按钮。 /// 按钮 value 携带 {approval_id, approved},点击触发 card.action.trigger 事件, /// 经 on_card_action → handle_card_action → ai_approve。发送失败降级文本卡(回复「批准/拒绝」)。 async fn send_approval_button_card( client: &LarkClient, chat_id: &str, approval_id: &str, tool_name: &str, brief: &str, ) { let detail = if brief.is_empty() { format!("⏸ 需审批: `{tool_name}`") } else { format!("⏸ 需审批: `{tool_name}`\n\n{brief}") }; // 按钮 value 需为 SDK 的 JsonValue(其实现了 From,直接 into 转换) let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { v.into() }; let actions = Action::new() .action(ActionComponent::Button( Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意")) .button_type(ButtonType::Primary) .value(to_json_value(serde_json::json!({ "approval_id": approval_id, "approved": true }))), )) .action(ActionComponent::Button( Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝")) .button_type(ButtonType::Danger) .value(to_json_value(serde_json::json!({ "approval_id": approval_id, "approved": false }))), )); let card = Card::new() .header(Header::new("🔐 工具审批请求")) .element(Element::Div(Div::new( larksuite_oapi_sdk_rs::card::v1::Text::lark_md(md_to_lark_friendly(&detail)), ))) .element(Element::Action(actions)); let document = match CardDocument::new(card) { Ok(d) => d, Err(e) => { tracing::warn!("[feishu] 审批按钮卡校验失败,降级文本卡: {e}"); let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续"); send_markdown_card(client, "", chat_id, &fallback).await; return; } }; let content = serde_json::to_string(document.card()).unwrap_or_default(); let input = SendInput { receive_id: Some(chat_id.to_string()), card: Some(content), ..Default::default() }; match client .channel_messaging() .send(&input, &RequestOption::default()) .await { Ok(sent) if !sent.message_id.is_empty() => { feishu_log(&format!("审批按钮卡已发 chat={chat_id} card={}", sent.message_id)); } Ok(_) => { tracing::warn!("[feishu] 审批按钮卡发送未返回 message_id"); } Err(e) => { tracing::error!("[feishu] 审批按钮卡发送失败,降级文本卡: {e}"); let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续"); send_markdown_card(client, "", chat_id, &fallback).await; } } } /// 该 conv 最新一条挂起审批的 tool_call_id(决议原语的消费目标);无挂起返回 None。 /// created_at 缺失视为最旧(宁取有时序的),同刻并列取遍历后者(HashMap 无序,可接受) async fn latest_pending_approval( state: &crate::state::AppState, conv_id: &str, ) -> Option { let session = state.ai_session.lock().await; session .pending_approvals .iter() .filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id)) .max_by_key(|(_, a)| { a.created_at .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0) }) .map(|(k, _)| k.clone()) } /// 按 tool_call_id 反查其所属 conversation_id(卡片回调 chat_id 缺失时反查补齐用) async fn approval_conversation_id( state: &crate::state::AppState, tool_call_id: &str, ) -> Option { let session = state.ai_session.lock().await; session .pending_approvals .get(tool_call_id) .and_then(|a| a.conversation_id.clone()) } /// 该 conv 挂起审批计数(多条挂起提示用):决议一条后仍有 N 条时提醒用户继续 async fn count_pending_approvals( state: &crate::state::AppState, conv_id: &str, ) -> usize { let session = state.ai_session.lock().await; session .pending_approvals .iter() .filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id)) .count() } /// D2 白名单热加载:每消息现读 KV,设置面板改白名单即生效免重启;读取/解析失败沿用 /// 上次缓存——拒收口径随缓存,不因 DB 抖动放大为误放行;成功时回写缓存供降级使用 async fn read_whitelist(ctx: &Ctx) -> Vec { let db = ctx.app.state::().db.clone(); let repo = SettingsRepo::new(&db); let raw = repo.get(KV_FEISHU_CONFIG).await.ok().flatten(); match raw.and_then(|r| serde_json::from_str::(&norm(Some(r))).ok()) { Some(cfg) => { *ctx.whitelist.write().await = cfg.whitelist.clone(); cfg.whitelist } None => { tracing::warn!("[feishu] 白名单 KV 读取/解析失败,沿用上次缓存"); ctx.whitelist.read().await.clone() } } } /// 流式预览节流间隔:飞书卡片编辑接口有 QPS 约束,3s 在可读性与配额间取衡(约束下限 2s) const STREAM_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); /// 流式预览上限字符数:对齐终版 clip_for_card 口径;超限从头截断保尾部 const STREAM_PREVIEW_MAX_CHARS: usize = 8000; /// 13a-2 静默期心跳阈值:超此秒数无本 conv 内容进展(delta/轮次/工具/进度),tick 强制刷心跳行。 /// 60s 取「长工具常见执行时长」与「卡片冻结感阈值」之间——再短会与常规刷新混叠, /// 再长用户已开始疑心卡死。心跳行此后每静默分钟刷一次(桶去重),内容恢复即自然停。 /// 注意:AiHeartbeat 等引擎保活事件不计入活动钟(非内容进展),否则心跳节拍会把静默永远压在阈值内 const HEARTBEAT_SILENT_THRESHOLD: u64 = 60; /// 13a-2 心跳刷判定(纯函数,单测锁定):内容静默达阈值且进入新的静默分钟桶时返回该桶号 /// (应刷),否则 None(不刷)。桶去重保证每个静默分钟只刷一次,内容恢复(静默回落)即停。 fn heartbeat_due(silent_secs: u64, painted_bucket: Option, threshold: u64) -> Option { if silent_secs < threshold { return None; } let bucket = silent_secs / 60; if Some(bucket) == painted_bucket { None } else { Some(bucket) } } /// 流式预览任务:订阅 ai_event_bus 的增量(delta/轮次/工具/进度)节流把累计内容 edit_card /// 进占位卡;受理卡/阶段提示/终版经帧通道(frame_rx)传入,本任务是占位卡唯一写者。 /// /// 竞态设计:占位卡在本任务存活期只有本任务写,中间态 tick 与帧投递同任务串行,上一次 /// edit_card 的 HTTP 响应返回后才可能发下一次,飞书服务端所见次序即代码次序,终版不会 /// 被迟到的中间态覆盖,故无需 AbortHandle。中间态刷失败不新发卡片回退(不值得为预览刷屏), /// 新发回退链归帧投递(deliver_result)独有。 async fn run_stream_refresher( ctx: Arc, conv_id: String, placeholder_id: Option, message_id: String, chat_id: String, mut delta_rx: tokio::sync::broadcast::Receiver, mut frame_rx: tokio::sync::mpsc::Receiver, ) { let mut acc = String::new(); // 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额 let mut acc_chars = 0usize; let mut painted_chars = 0usize; // ── 过程流增量(todo.md 13):轮次/工具轨迹提炼,卡片头部一行摘要 ── // 长任务(206 轮级)纯工具轮无文本增量,原版卡片静止疑似卡死;轨迹行给出「在干活」证据。 // 提炼原则:决策价值过滤——只留「第 N 轮 + 当前工具名」,历史工具折叠计数,不刷屏不顶正文。 let mut round: Option = None; let mut current_tool: Option = None; // 13a-2 最近动作:工具完成后 current_tool 清空,但「最近在干什么」仍有展示价值(心跳行数据源) let mut last_tool: Option = None; let mut tool_steps: usize = 0usize; let mut painted_sig = String::new(); // 13a-3 阶段进度:最近一次 AiTaskProgress 快照(round/max/tokens),心跳行与头行数据源 let mut progress_round: Option = None; let mut progress_max: Option = None; let mut progress_tokens: Option = None; // 13a-1 刷新可观测:流式刷失败连败计数(成功归零;==阈值告警一次,继续累计防重复告警) let mut refresh_failures: u32 = 0; // 13a-2 静默期心跳:最后内容进展时刻(delta/轮次/工具/进度;不含 AiHeartbeat);静默超阈值 // 强制刷心跳行,防长工具/LLM 长往返期间卡片冻结在旧帧,用户误判卡死(todo.md 13a-2) let mut last_activity = tokio::time::Instant::now(); let mut painted_hb_bucket: Option = None; // 受理卡(首帧)到达前不写卡:防心跳/预览帧先于受理卡落卡,次序错乱 let mut started = false; let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL); // 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { // 帧通道:受理卡/阶段提示/终版。走 deliver_result(净化+裁剪+新发兜底),与预览同任务串行 frame = frame_rx.recv() => { let Some(f) = frame else { // 发送端丢弃(watcher 退出/受理失败):无更多帧,收工 return; }; deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &f.text).await; started = true; if f.terminal { return; } } item = delta_rx.recv() => match item { Ok(v) => { // conv 路由过滤(全事件共用);type tag 即变体名(AiChatEvent 无 serde rename) if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) { continue; } match v.get("type").and_then(|t| t.as_str()) { Some("AiTextDelta") => { if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) { acc.push_str(delta); acc_chars += delta.chars().count(); // 首帧提速(2026-10-08):首个 delta 到达且受理卡已落地即立即刷占位卡, // 不等 3s ticker——短响应(简单问答/翻译)原需等满一个节流周期才见首字, // 感知停顿明显。仅首帧走即时刷,后续仍按 3s 节流守飞书 QPS 配额 if started && painted_chars == 0 && acc_chars > 0 { if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) { let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens)); if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, None, progress_snapshot)).await { painted_chars = acc_chars; } } } } } // 轮次推进:立即标脏强制下一 tick 刷卡(纯工具轮无文本也有"活着"信号) Some("AiAgentRound") => { if let Some(r) = v.get("round").and_then(|r| r.as_u64()) { round = Some(r as u32); painted_chars = painted_chars.saturating_sub(1); // 标脏:强制重刷 } } // 工具开始:更新当前工具名;完成(含失败)即清空当前工具 Some("AiToolCallStarted") => { if let Some(name) = v.get("name").and_then(|n| n.as_str()) { current_tool = Some(name.to_string()); last_tool = Some(name.to_string()); tool_steps += 1; painted_chars = painted_chars.saturating_sub(1); // 标脏 } } Some("AiToolCallCompleted") => { current_tool = None; painted_chars = painted_chars.saturating_sub(1); // 标脏 } // 13a-3 阶段进度:每 5 轮一次的里程碑(mod.rs PROGRESS_EVERY_N_ROUNDS), // 立即标脏强制下一 tick 把「N/max 轮 · 累计 token」刷进卡片(长任务阶段可见) Some("AiTaskProgress") => { if let Some(r) = v.get("round").and_then(|r| r.as_u64()) { progress_round = Some(r as u32); progress_max = v.get("max_rounds").and_then(|m| m.as_u64()).map(|m| m as u32); progress_tokens = v.get("tokens_used").and_then(|t| t.as_u64()); painted_chars = painted_chars.saturating_sub(1); // 标脏 } } // AiHeartbeat 等引擎保活事件不重置内容静默钟:否则 20s 心跳节拍会把静默 // 永远压在阈值内,13a-2 心跳行永不触发(静默指内容进展停顿,非事件停顿) _ => continue, } // 13a-2 活动时钟:本 conv 内容事件(delta/轮次/工具/进度)算活着,重置静默计时 last_activity = tokio::time::Instant::now(); } // 预览允许丢帧:终版事实源在帧通道,消费落后只降级预览不断链路 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)"); } // 总线随 AppState 常驻理论不关;防御性收口:总线关闭即收工(进程收尾) Err(tokio::sync::broadcast::error::RecvError::Closed) => return, }, _ = ticker.tick() => { // 受理卡未落地前不刷:防预览/心跳帧抢先覆盖受理卡 if !started { continue; } // 占位缺失(首张卡片 reply 失败)时无从原地刷,保持静默等终版走新发回退 let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else { continue; }; // ── 13a-2 静默期心跳:内容静默达阈值且进入新静默分钟桶 → 刷心跳行(纯函数判定) ── // 长工具/LLM 长往返期间无 delta 无轮次推进,卡片静止;心跳行给出「引擎活着+ // 进行到哪」证据。桶去重保证每静默分钟只刷一次,内容恢复即自然停 let silent_secs = last_activity.elapsed().as_secs(); let hb_bucket = heartbeat_due(silent_secs, painted_hb_bucket, HEARTBEAT_SILENT_THRESHOLD); let hb_needed = hb_bucket.is_some(); // 常规信号标脏:过程事件已回拨 painted_chars,字符数不等即刷 let dirty = acc_chars != painted_chars; if !dirty && !hb_needed { continue; } // 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷) let sig = format!("{}|{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps, hb_bucket.unwrap_or(u64::MAX)); if sig == painted_sig { continue; } // 13a-1 刷新可观测:刷失败落盘+连败计数,≥3 连败告警一次(计数继续累计, // 重置仅在成功后;防每 tick 重复告警刷屏)。排查凭据:刷了没刷/刷败原因全可查 let silent_for_preview = if hb_needed { Some(silent_secs) } else { None }; let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens)); if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, silent_for_preview, progress_snapshot)).await { if refresh_failures > 0 { feishu_log(&format!("[refresher] 刷新恢复(此前连败 {refresh_failures} 次)")); } refresh_failures = 0; painted_chars = acc_chars; painted_sig = sig; if let Some(b) = hb_bucket { painted_hb_bucket = Some(b); // 心跳桶已刷,本静默分钟不再刷 } } else { refresh_failures += 1; feishu_log(&format!( "[refresher] update_card 失败(连败 {refresh_failures}) chat={chat_id} msg={pid} chars={acc_chars}" )); if refresh_failures == REFRESH_FAIL_ALERT_THRESHOLD { feishu_log(&format!( "[refresher] ⚠️ 连续 {refresh_failures} 次刷新失败,卡片可能冻结——流式预览降级,终版仍走帧通道(不受影响)" )); } } } } } } /// 流式中间态卡片:头部一行过程摘要(轮次/工具轨迹提炼)+ 正文流式增量(保尾截断)。 /// /// 提炼原则(todo.md 13「决策价值过滤」):历史步骤折叠为计数,只透出「当前在干什么」—— /// 读者要的是"活着没卡死+进行到哪",不是 20 个工具名的流水墙;正文仍以流式文本为主, /// 纯工具轮(无文本)时头部行是唯一进度证据。13a-2:静默超阈值时追加心跳行 /// 「💓 仍在跑:第 X 轮 · 最近动作 · 静默 Ys」——长工具/LLM 长往返期间卡片冻结疑似 /// 卡死,心跳行是「引擎活着」的用户可见证据。 fn stream_preview( acc: &str, round: Option, current_tool: Option<&str>, last_tool: Option<&str>, tool_steps: usize, silent_secs: Option, progress: Option<(u32, Option, Option)>, ) -> String { // 头部摘要行:轮次 + 当前工具(执行中)/已执行步数;全部缺省(首轮文本前)不显头行。 // 13a-3:round 缺省时回退 AiTaskProgress 的 round(里程碑轮快照),并附累计 token let mut head = String::new(); let (disp_round, prog_suffix) = match (&round, &progress) { (Some(r), _) => (Some(*r), String::new()), (None, Some((pr, max, tokens))) => { let max_part = match max.filter(|m| *m > 0) { Some(m) => format!("/{m} 轮"), None => String::new(), }; let tok_part = tokens.map(|t| format!(" · 累计 {t} tokens")).unwrap_or_default(); (Some(*pr), format!("{max_part}{tok_part}")) } (None, None) => (None, String::new()), }; if disp_round.is_some() || tool_steps > 0 { let r = disp_round.map(|n| format!("第 {n} 轮")).unwrap_or_default(); let t = match current_tool { Some(name) => format!("⚙ `{name}` 执行中"), None => format!("已完成 {tool_steps} 步工具调用"), }; head = format!("**{r}{prog_suffix} · {t}**\n\n"); } // 13a-2 心跳行:静默超阈值(refresher 判定后传入)时头部追加,数据源=轮次+最近工具。 // 动作优先级:执行中工具名 > 最近工具名 > 步数/思考中(last_tool_fallback) if let Some(secs) = silent_secs { let r = disp_round.map(|n| format!("第 {n} 轮")).unwrap_or_else(|| "启动".to_string()); let action = match current_tool.or(last_tool) { Some(name) => format!("`{name}` 执行中"), None => format!("最近动作 `{}`", last_tool_fallback(tool_steps)), }; head = format!("💓 **仍在跑:{r} · {action} · 静默 {secs}s**\n\n{head}"); } // 正文:超上限保尾截断(对齐原口径) let total = acc.chars().count(); let body = if total > STREAM_PREVIEW_MAX_CHARS { let tail: String = acc.chars().skip(total - STREAM_PREVIEW_MAX_CHARS).collect(); format!("…(前文已略)\n{tail}") } else { acc.to_string() }; format!("{head}{body}\n▌") } /// 13a-2 心跳行最近动作回退:current_tool 已清空(工具完成、等 LLM 响应)时的展示文案。 /// tool_steps>0 用步数表述「刚干完 N 步」,零步(纯 LLM 长思考)退「LLM 思考中」 fn last_tool_fallback(tool_steps: usize) -> String { if tool_steps > 0 { format!("{tool_steps} 步工具调用") } else { "LLM 思考中".to_string() } } /// markdown 回复:interactive 卡片承载 /// 出站先过 38a 净化(md_to_lark_friendly),成功返回新卡片 message_id(34 滚动汇总卡 /// 绑定句柄用,取 id 方式与 reply_markdown_card 同款);彻底失败回退纯文本保可达,返 None pub(crate) async fn send_markdown_card( client: &LarkClient, message_id: &str, chat_id: &str, text: &str, ) -> Option { let text = &md_to_lark_friendly(text); let card = Card::new().element(Element::Markdown(MdElement::new(text.to_string()))); let document = match CardDocument::new(card) { Ok(d) => d, Err(e) => { tracing::warn!("[feishu] 卡片校验失败,回退纯文本: {e}"); reply_message(client, message_id, chat_id, text).await; return None; } }; let content = serde_json::to_string(document.card()).unwrap_or_default(); let input = SendInput { receive_id: Some(chat_id.to_string()), card: Some(content), ..Default::default() }; let opt = &RequestOption::default(); let mut replied: Option = None; if !message_id.is_empty() { if let Ok(sent) = client.channel_messaging().reply(message_id, &input, opt).await { if !sent.message_id.is_empty() { replied = Some(sent.message_id); } } } if replied.is_some() { return replied; } match client.channel_messaging().send(&input, opt).await { Ok(sent) if !sent.message_id.is_empty() => Some(sent.message_id), Ok(_) => None, Err(e) => { tracing::error!("[feishu] 卡片发送失败: {e}"); // 卡片彻底失败仍回退纯文本,保可达 reply_message(client, message_id, chat_id, text).await; None } } } /// reply 一张 markdown 卡片,成功返回卡片消息 id(完成后原地更新用) /// (SDK strict reply 内部已校验业务码,Ok 即业务成功;id 为空视为失败) async fn reply_markdown_card(client: &LarkClient, message_id: &str, text: &str) -> Option { let card = Card::new().element(Element::Markdown(MdElement::new(text.to_string()))); let document = match CardDocument::new(card) { Ok(d) => d, Err(e) => { tracing::warn!("[feishu] 占位卡片构建失败: {e}"); return None; } }; let content = serde_json::to_string(document.card()).unwrap_or_default(); // strict reply 只认 message_id 参数,携带 receive_id 等接收方字段会被 SDK 拒绝 let input = SendInput { card: Some(content), ..Default::default() }; match client .channel_messaging() .reply(message_id, &input, &RequestOption::default()) .await { Ok(sent) if !sent.message_id.is_empty() => Some(sent.message_id), Ok(_) => None, Err(e) => { tracing::warn!("[feishu] 占位卡片回复失败: {e}"); None } } } /// 原地更新卡片(占位→结果,不刷屏)。edit_card 返回 EmptyResp:HTTP 200 但业务 code≠0 /// (卡片被撤回/JSON 超限等)时 err 为 nil 伪成功,必须再校验 success async fn update_card(client: &LarkClient, message_id: &str, text: &str) -> bool { let card = match CardDocument::new(Card::new().element(Element::Markdown(MdElement::new( text.to_string(), )))) { Ok(d) => d, Err(e) => { tracing::warn!("[feishu] 结果卡片构建失败: {e}"); return false; } }; match client .channel_messaging() .edit_card(message_id, &card, &RequestOption::default()) .await { Ok(resp) if resp.success() => true, Ok(resp) => { tracing::warn!("[feishu] 卡片更新业务失败 code={:?}", resp.code_error); false } Err(e) => { tracing::warn!("[feishu] 卡片更新失败: {e}"); false } } } /// 结果投递统一入口:先做出站净化(38a,update_card 原地路径不经 send_markdown_card, /// 须在此净化;回退新发路径二次进入幂等)与裁剪,优先原地更新占位卡片(占位与结果合一); /// 占位缺失或更新失败回退新发卡片(内含纯文本兜底) async fn deliver_result( client: &LarkClient, placeholder_id: &Option, message_id: &str, chat_id: &str, text: &str, ) { let text = clip_for_card(&md_to_lark_friendly(text), 8000); if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) { if update_card(client, pid, &text).await { return; } feishu_log("占位卡片更新失败,回退新发卡片"); } let _ = send_markdown_card(client, message_id, chat_id, &text).await; } /// 19 outbound 统一出口:主动推送(无占位卡上下文的外部推送方——挂机进度汇总/巡检通知 /// 等将来形态)。降级/裁剪/卡片链与 deliver_result 同源(禁再开独立发送路径绕过降级链, /// 11:23 实证:绕过链的回复带完整 md 表格未降级,踩回表格渲染不稳坑)。 /// 34 滚动汇总卡:同 chat 在途事务卡存在时原地追加(句柄见 ROLLING_CARDS),✅/❌ 终态 /// 更新后清句柄下条另起新卡;原地更新失败清句柄回退新发并重绑(比照 deliver_result 兜底)。 /// 29 推送路由:conv_hint 按绑定关系精确反查 chat;缺省/未命中时单绑定直用(单绑定行为 /// 不变),多绑定取最近活跃,不再取 KV 首条盲投。渠道未启动(无 client)或无映射时静默跳过。 /// 19 正典出口预留给将来推送方(挂机进度汇总/巡检通知),当前无调用方 #[allow(dead_code)] pub(crate) async fn push_to_chat(app: &tauri::AppHandle, text: &str, conv_hint: Option<&str>) { let Some(client) = ACTIVE_CLIENT.get() else { return; // 渠道未启动:推送无落点,静默跳过(调用方自行决定是否记日志) }; let db = app.state::().db.clone(); let repo = SettingsRepo::new(&db); let raw = repo.get(KV_CHAT_MAP).await.ok().flatten(); let Some(map) = raw.and_then(|r| serde_json::from_str::>(&norm(Some(r))).ok()) else { return; }; let Some(chat_id) = resolve_push_chat(&db, &map, conv_hint).await else { return; }; // 34 终态判据:✅/❌ 开头即事务收尾,更新后不再续卡 let terminal = is_terminal_push(text); let rolling = ROLLING_CARDS.get_or_init(|| std::sync::Mutex::new(HashMap::new())); let handle = rolling.lock().ok().and_then(|m| m.get(&chat_id).cloned()); if let Some((card_id, mut acc)) = handle { acc.push('\n'); acc.push_str(text); let rendered = clip_for_card(&md_to_lark_friendly(&acc), 8000); if update_card(client, &card_id, &rendered).await { if terminal { if let Ok(mut m) = rolling.lock() { m.remove(&chat_id); } } feishu_log(&format!("push_to_chat 追加 chat={chat_id} len={}", rendered.len())); return; } // 原地更新失败(卡片被撤回/JSON 超限等):清句柄,新发并重绑 if let Ok(mut m) = rolling.lock() { m.remove(&chat_id); } let rebound = send_markdown_card(client, "", &chat_id, &rendered).await; if let (false, Some(new_id)) = (!terminal, rebound) { if let Ok(mut m) = rolling.lock() { m.insert(chat_id.clone(), (new_id, acc)); } } feishu_log(&format!("push_to_chat 回退重绑 chat={chat_id} len={}", rendered.len())); return; } let processed = clip_for_card(&md_to_lark_friendly(text), 8000); // 主动推送无 message_id 上下文:send_markdown_card 的 reply 分支跳过,直接 chat 卡片 let sent_id = send_markdown_card(client, "", &chat_id, &processed).await; // 首推开卡绑定句柄;终态不绑,下条另起新卡 if let (false, Some(new_id)) = (!terminal, sent_id) { if let Ok(mut m) = rolling.lock() { m.insert(chat_id.clone(), (new_id, text.to_string())); } } feishu_log(&format!("push_to_chat chat={chat_id} len={} terminal={terminal}", processed.len())); } /// 29 推送路由裁决:conv_hint 有绑定命中即用(多命中取最近活跃);缺省/未命中时单绑定 /// 直用(行为与旧首条取法一致),多绑定取最近活跃。无绑定返回 None 静默跳过 async fn resolve_push_chat( db: &df_storage::db::Database, map: &HashMap, conv_hint: Option<&str>, ) -> Option { if let Some(hint) = conv_hint { let hits: Vec<&String> = map .iter() .filter(|(_, c)| c.as_str() == hint) .map(|(k, _)| k) .collect(); match hits.len() { 1 => return Some(hits[0].clone()), n if n > 1 => return most_recent_chat(db, &hits).await, _ => {} // hint 未命中:绑定可能已重建,落多绑定裁决 } } match map.len() { 0 => None, 1 => map.keys().next().cloned(), _ => { let all: Vec<&String> = map.keys().collect(); most_recent_chat(db, &all).await } } } /// 多绑定裁决:逐 conv 取最后一条消息时间戳,最新者胜;全部无消息取遍历首个(稳定退化) async fn most_recent_chat(db: &df_storage::db::Database, chats: &[&String]) -> Option { use df_storage::crud::AiMessageRepo; let repo = AiMessageRepo::new(db); let mut best: Option<(&String, i64)> = None; for cid in chats { let ts = repo .list_by_conversation(cid) .await .ok() .and_then(|msgs| msgs.last().and_then(|m| m.timestamp)) .unwrap_or(i64::MIN); if best.map_or(true, |(_, bts)| ts > bts) { best = Some((cid, ts)); } } best.map(|(k, _)| (*k).clone()) } /// 卡片正文长度防御:飞书卡片 JSON 约 30KB 上限,超长会让原地更新与新发双双失败, /// 一路穿透到纯文本兜底仍不可读;入口统一裁剪,尾注指回桌面端(u-claw archiveIfClipped 同口径) fn clip_for_card(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_string(); } let clipped: String = text.chars().take(max_chars).collect(); format!("{clipped}\n\n---\n(内容过长已截断,完整内容见桌面端会话)") } /// 回复到原消息(reply 落回消息所在话题/上下文,修复回复跑到群主界面的问题);失败回退 chat 直发 async fn reply_message(client: &LarkClient, message_id: &str, chat_id: &str, text: &str) { let opt = &RequestOption::default(); if !message_id.is_empty() { if let Err(e) = client.channel_messaging().reply_text(message_id, text, false, opt).await { tracing::warn!("[feishu] reply 失败,回退 chat 直发: {e}"); } else { return; } } send_text(client, chat_id, text).await; } async fn send_text(client: &LarkClient, chat_id: &str, text: &str) { let input = SendInput { receive_id: Some(chat_id.to_string()), text: Some(text.to_string()), ..Default::default() }; if let Err(e) = client.channel_messaging().send(&input, &RequestOption::default()).await { tracing::error!("[feishu] 发送失败: {e}"); } } /// 剥离飞书 @ 占位串(如 @_user_1),@ 语义已由 mentioned_bot 承载。 /// 占位符固定形态 `@_user_` + 连续数字:只剥此形态(顺带吞其后单个空格防双空格); /// 前缀后非数字(如 @_user_x)按字面保留。不能「找空白跳」——中文无空格 /// (@_user_1帮我查)会整段吞掉致消息被静默丢弃(review 2026-10-05 ①)。 fn strip_mention_placeholders(text: &str) -> String { const MARK: &str = "@_user_"; let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find(MARK) { let after = &rest[i + MARK.len()..]; let digit_len: usize = after .chars() .take_while(|c| c.is_ascii_digit()) .map(char::len_utf8) .sum(); if digit_len == 0 { // 非占位形态:MARK 字面保留,rest 从 MARK 之后继续扫描(严格前进防死循环) out.push_str(&rest[..i + MARK.len()]); } else { out.push_str(&rest[..i]); rest = &after[digit_len..]; if let Some(stripped) = rest.strip_prefix(' ') { rest = stripped; } } rest = if digit_len == 0 { after } else { rest }; } out.push_str(rest); out.trim().to_string() } /// md 表格降级:表格块(连续 | 开头行且第二行为 |---| 分隔形态)转「- 列1:列2」列表行, /// 单元格内既有行内格式(粗体等)原样保留,表格前后非表格文本不动。 /// 表头也转列表且单元格加粗:列名信息不丢,且避免 | 行残留导致竖线裸露 fn downgrade_md_tables(text: &str) -> String { let lines: Vec<&str> = text.lines().collect(); let mut out: Vec = Vec::with_capacity(lines.len()); let mut i = 0; while i < lines.len() { if is_table_row(lines[i]) && i + 1 < lines.len() && is_table_separator(lines[i + 1]) { out.push(table_line_to_list(lines[i], true)); i += 2; // 表头已转,分隔行只是语法标记,丢弃 while i < lines.len() && is_table_row(lines[i]) { out.push(table_line_to_list(lines[i], false)); i += 1; } continue; } out.push(lines[i].to_string()); i += 1; } out.join("\n") } fn is_table_row(line: &str) -> bool { line.trim_start().starts_with('|') } /// 38a 出站 md 净化:md 转飞书友好格式。lark_md 不支持标题/表格等全谱语法,LLM 产出的 /// #/##/| 原样出站即符号裸露(38 实证)。规则母本三条:标题行转粗体行、表格转列表行 /// (复用 downgrade_md_tables 单一实现)、代码块围栏内原样保留,其余原样。 /// 幂等:粗体行/列表行二次进入不再变形,send_markdown_card 与 push_to_chat 链重复 /// 调用安全。df-im-gateway StreamText 出站侧做净化时须对齐同一规则(同规则互引, /// 38 双层防线的出站兜底层,以本函数为正典)。 pub fn md_to_lark_friendly(text: &str) -> String { let mut out: Vec = Vec::new(); let mut plain: Vec<&str> = Vec::new(); let mut in_fence = false; for line in text.lines() { if line.trim_start().starts_with("```") { if !in_fence { out.extend(sanitize_plain_lines(&plain)); plain.clear(); } in_fence = !in_fence; out.push(line.to_string()); continue; } if in_fence { out.push(line.to_string()); } else { plain.push(line); } } out.extend(sanitize_plain_lines(&plain)); out.join("\n") } /// 围栏外逐段净化:表格降级 + 标题行转粗体行 fn sanitize_plain_lines(lines: &[&str]) -> Vec { let joined = lines.join("\n"); downgrade_md_tables(&joined) .lines() .map(|line| match heading_content(line) { Some(content) if !content.is_empty() => format!("**{content}**"), _ => line.to_string(), }) .collect() } /// 标题行识别:1 到 6 个 # 后接空格(CommonMark 形态,防 #标签 之类误伤) fn heading_content(line: &str) -> Option<&str> { let t = line.trim_start(); let hashes = t.chars().take_while(|&c| c == '#').count(); if (1..=6).contains(&hashes) { if let Some(rest) = t[hashes..].strip_prefix(' ') { return Some(rest.trim_end()); } } None } /// 34 终态判据:文本以 ✅/❌ 开头(对齐看板符号语义规范:✅ 仅限完成且成功,❌ 失败须附原因) /// 视为事务收尾,滚动汇总卡更新后清句柄,下条推送另起新卡 fn is_terminal_push(text: &str) -> bool { let t = text.trim_start(); t.starts_with('✅') || t.starts_with('❌') } /// 分隔行形态:| 开头,且整行只由 | - : 与空白构成并含至少一段 --- fn is_table_separator(line: &str) -> bool { let t = line.trim(); t.starts_with('|') && t.contains("---") && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) } /// 单行表格转列表行;bold=true 用于表头行,加粗区隔列名 fn table_line_to_list(line: &str, bold: bool) -> String { // 单元格内 \| 是竖线字面量,先占位再还原,防被当列分隔切开 const ESC: char = '\u{0}'; let cells: Vec = line .trim() .trim_matches('|') .replace("\\|", &ESC.to_string()) .split('|') .map(|c| c.trim().replace(ESC, "\\|")) .filter(|c| !c.is_empty()) .map(|c| { if bold { format!("**{c}**") } else { c } }) .collect(); format!("- {}", cells.join(":")) } /// 13a-1 刷新可观测:流式刷连败告警阈值(== 触发一次,继续累计不重复告警) const REFRESH_FAIL_ALERT_THRESHOLD: u32 = 3; /// 关键事件日志落盘(零依赖 fs append;Roaming 下 feishu-channel.log,排查空回复等复现凭据) pub(crate) fn feishu_log(line: &str) { use std::io::Write; if let Ok(dir) = std::env::var("APPDATA") { let p = std::path::Path::new(&dir).join("top.1216.devflow").join("feishu-channel.log"); if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(p) { let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()).unwrap_or(0); let _ = writeln!(f, "[{ts}] {line}"); } } } fn truncate(s: &str, n: usize) -> String { s.chars().take(n).collect() } /// #22 时间戳过滤判据(纯函数,单测锁定):给定受理时刻时只认其后落库的消息, /// 消息缺时间戳视为不在边界内(宁缺勿冒充旧回答);无受理时刻不过滤 fn msg_after_acceptance(msg_ts: Option, accepted_ms: Option) -> bool { accepted_ms.map_or(true, |ts| msg_ts.map_or(false, |t| t > ts)) } /// 回查该会话最后一条非空 assistant 文本(通知桥空内容时的事实源兜底)。 /// #22 任务边界:only_after_ms 传入受理时刻(Unix 毫秒)时只认该时刻之后的消息, /// 防 35min 兜底回查把上一任务旧回答冒充本次结果;None 不过滤(旧语义)。 async fn last_assistant_reply( db: &df_storage::db::Database, conv_id: &str, only_after_ms: Option, ) -> Option { use df_storage::crud::AiMessageRepo; let repo = AiMessageRepo::new(db); let msgs = repo.list_by_conversation(conv_id).await.ok()?; msgs.into_iter() .rev() .find(|m| { m.role == "assistant" && !m.content.trim().is_empty() && msg_after_acceptance(m.timestamp, only_after_ms) }) .map(|m| m.content) } async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap) { let repo = SettingsRepo::new(db); let json = serde_json::to_string(map).unwrap_or_default(); let _ = repo.set(KV_CHAT_MAP, &json).await; } #[cfg(test)] mod tests { use super::{ 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, }; #[test] fn delivery_paths_extract_only_marked_lines() { // #958 出站回传:只认 [交付] 标记行,叙述路径不发送;全半角冒号容错 let text = "方案如下。\n已生成图表:\n[交付] E:\\out\\chart.png\n另见 E:\\ref\\说明.docx\n[交付]:/home/u/报表 最终.xlsx\n[交付]"; assert_eq!( extract_delivery_paths(text), vec!["E:\\out\\chart.png".to_string(), "/home/u/报表 最终.xlsx".to_string()] ); assert!(extract_delivery_paths("没有标记").is_empty()); } #[test] fn delivery_blocked_catches_credential_names() { assert!(delivery_blocked("E:\\proj\\.env").is_some()); assert!(delivery_blocked("/home/u/id_rsa").is_some()); assert!(delivery_blocked("E:\\app\\API_KEY.txt").is_some()); assert_eq!(delivery_blocked("E:\\out\\chart.png"), None); assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None); } #[test] fn feishu_file_type_maps_common_exts() { assert_eq!(feishu_file_type("pdf"), "pdf"); assert_eq!(feishu_file_type("xlsx"), "xls"); assert_eq!(feishu_file_type("docx"), "doc"); assert_eq!(feishu_file_type("mp4"), "mp4"); assert_eq!(feishu_file_type("zip"), "stream"); assert_eq!(feishu_file_type(""), "stream"); } #[test] fn image_mime_sniff_four_formats() { // #958:四格式魔数命中 + 未知格式拒收(宁早拒不晚炸 vision 端点 400) assert_eq!(sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), Some("image/png")); assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); assert_eq!(sniff_image_mime(b"GIF89a"), Some("image/gif")); assert_eq!( sniff_image_mime(b"RIFF\x24\x08\x00\x00WEBPVP8 "), Some("image/webp") ); assert_eq!(sniff_image_mime(b""), None); assert_eq!(sniff_image_mime(&[]), None); // RIFF 但非 WEBP(avi/wav 等)不误判 assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None); } // ── 文件消息入站:任务语境构造/大小限流/展示名回退 ── #[test] fn file_prompt_carries_name_path_and_read_guidance() { // 落盘后终版:文件名+本机路径+read_file 引导+读取局限交底,四要素齐备 let full = file_inbound_prompt("报表.xlsx", Some("E:\\data\\media\\ab12_报表.xlsx")); assert!(full.contains("「报表.xlsx」"), "缺文件名: {full}"); assert!(full.contains("本地路径 E:\\data\\media\\ab12_报表.xlsx"), "缺路径: {full}"); assert!(full.contains("read_file"), "缺读取引导: {full}"); assert!(full.contains("如实告知用户"), "缺读取局限交底: {full}"); // 落盘前占位版:不含路径(不喂模型不落卡,仅供空判与命令/审批判定) let pending = file_inbound_prompt("报表.xlsx", None); assert!(!pending.contains("本地路径"), "占位版不该有路径: {pending}"); assert!(pending.contains("「报表.xlsx」")); } #[test] fn file_size_limit_rejects_over_20mb_only() { // 恰 20MB 放行(> 判定),超限给可读错误话术(含实际大小,引导压缩/拆分) assert_eq!(file_size_over_limit_error(20 * 1024 * 1024), None); assert_eq!(file_size_over_limit_error(1024), None); let err = file_size_over_limit_error(21 * 1024 * 1024).unwrap(); assert!(err.contains("20MB") && err.contains("21MB"), "话术缺限值/实际值: {err}"); } #[test] fn file_display_name_prefers_original_and_hash_fallback() { // 原始名保真(清洗首尾空白);缺失回退 file_,不同内容回退名不同 assert_eq!(file_display_name(" 报表.xlsx ", b"abc"), "报表.xlsx"); let fallback = file_display_name("", b"abc"); assert!(fallback.starts_with("file_"), "回退名形态: {fallback}"); assert_eq!(fallback.len(), "file_".len() + 8, "回退名取 hash 前 8: {fallback}"); assert_ne!( fallback, file_display_name(" ", b"abcd"), "不同内容的回退名应可区分" ); } #[test] fn command_case_and_tail_tolerance() { // 30:/new 大小写与附言容错,附言不烧 LLM assert_eq!(parse_command("/new"), Some(("new".to_string(), String::new()))); assert_eq!( parse_command("/New 帮我开新会话"), Some(("new".to_string(), "帮我开新会话".to_string())) ); assert_eq!(parse_command("/STATUS"), Some(("status".to_string(), String::new()))); } #[test] fn command_fullwidth_normalized() { // 30:半全角混排,全角斜杠/字母/空格归一后识别 assert_eq!(parse_command("/new"), Some(("new".to_string(), String::new()))); assert_eq!( parse_command("/STATUS 附加"), Some(("status".to_string(), "附加".to_string())) ); } #[test] fn command_none_and_unknown_fallthrough() { // 纯任务文本与裸斜杠不是命令 assert!(parse_command("帮我走查代码").is_none()); assert!(parse_command("/").is_none()); // 未知命令词解析得出,由调用方落回常规任务路径(零行为变更);无空格路径整体为命令词 assert_eq!( parse_command("/usr/bin 定位").map(|(c, _)| c), Some("usr/bin".to_string()) ); } #[test] fn mention_cn_no_space_not_swallowed() { // review ①:中文紧邻占位(无空白)不能整段吞——此前 skip 找空白,skip=len 吞全文 assert_eq!(strip_mention_placeholders("@_user_1帮我查任务"), "帮我查任务"); } #[test] fn mention_prefix_and_digits_only_stripped() { // 只剥 @_user_ + 数字,数字后内容保留(含标点/英文) assert_eq!(strip_mention_placeholders("@_user_12 hi"), "hi"); assert_eq!(strip_mention_placeholders("@_user_1,今天天气"), ",今天天气"); // 非数字紧跟(非官方占位形态)不误剥前缀 assert_eq!(strip_mention_placeholders("@_user_x 保留"), "@_user_x 保留"); } #[test] fn mention_multiple_and_plain_text_untouched() { assert_eq!(strip_mention_placeholders("a @_user_1 b @_user_2 c"), "a b c"); assert_eq!(strip_mention_placeholders("普通消息不动"), "普通消息不动"); assert_eq!(strip_mention_placeholders("@_user_1"), ""); } // ── 过程流增量摘要提炼(todo.md 13):头部行格式契约 ── #[test] fn preview_first_text_no_head() { // 首轮纯文本(无轮次无工具):不显头行,原文+光标 let out = stream_preview("你好", None, None, None, 0, None, None); assert_eq!(out, "你好\n▌"); } #[test] fn preview_tool_running_head() { // 工具执行中:头行 = 轮次 + ⚙ 工具名 执行中 let out = stream_preview("分析中", Some(3), Some("read_file"), None, 5, None, None); assert!(out.starts_with("**第 3 轮 · ⚙ `read_file` 执行中**\n\n")); assert!(out.contains("分析中")); assert!(out.ends_with("▌")); } #[test] fn preview_tool_done_head() { // 工具轮间空档(当前无工具):头行转「已完成 N 步」——纯工具轮的"活着"证据 let out = stream_preview("", Some(4), None, None, 7, None, None); assert!(out.starts_with("**第 4 轮 · 已完成 7 步工具调用**\n\n")); } #[test] fn preview_tail_keep_on_overflow() { // 超上限保尾截断 + 前缀标注;头行不受截断影响 let long = "x".repeat(9000); let out = stream_preview(&long, Some(1), None, None, 1, None, None); assert!(out.starts_with("**第 1 轮 · 已完成 1 步工具调用**")); assert!(out.contains("…(前文已略)")); assert!(out.chars().count() < 8500); // 头行 + 8000 尾 + 标注 } #[test] fn preview_heartbeat_line_on_silent() { // 13a-2 静默超阈值:心跳行置顶(在常规头行之前),含轮次/动作/静默秒数 let out = stream_preview("分析中", Some(3), Some("run_command"), None, 5, Some(67), None); assert!(out.starts_with("💓 **仍在跑:第 3 轮 · `run_command` 执行中 · 静默 67s**\n\n")); assert!(out.contains("**第 3 轮 · ⚙ `run_command` 执行中**")); // 常规头行保留 } #[test] fn preview_heartbeat_tool_done_fallback() { // 13a-2 静默且工具间空档:动作优先用最近工具名,无则回退步数表述 let out = stream_preview("", Some(4), None, Some("grep"), 9, Some(95), None); assert!(out.starts_with("💓 **仍在跑:第 4 轮 · `grep` 执行中 · 静默 95s**\n\n")); // 无最近工具(纯 LLM 长思考)回退 let out2 = stream_preview("", Some(4), None, None, 9, Some(95), None); assert!(out2.starts_with("💓 **仍在跑:第 4 轮 · 最近动作 `9 步工具调用` · 静默 95s**\n\n")); } #[test] fn preview_heartbeat_no_round_no_steps() { // 13a-2 静默且零步零轮(纯 LLM 长思考):轮次回退「启动」,动作回退「LLM 思考中」 let out = stream_preview("开头文本", None, None, None, 0, Some(80), None); assert!(out.starts_with("💓 **仍在跑:启动 · 最近动作 `LLM 思考中` · 静默 80s**\n\n")); assert!(out.contains("开头文本\n▌")); } #[test] fn preview_progress_fallback_round_when_no_agent_round() { // 13a-3:AiAgentRound 缺席(纯进度事件场景)时,头行回退 AiTaskProgress 快照 // 并附 max 轮与累计 token;max=0(不限轮)不显上限 let out = stream_preview("干活中", None, None, None, 2, None, Some((10, Some(50), Some(123456)))); assert!(out.starts_with("**第 10 轮/50 轮 · 累计 123456 tokens · 已完成 2 步工具调用**")); // max_rounds=0 = 不限轮次,只显 token let out2 = stream_preview("干活中", None, None, None, 2, None, Some((10, Some(0), Some(999)))); assert!(out2.starts_with("**第 10 轮 · 累计 999 tokens · 已完成 2 步工具调用**")); } #[test] fn preview_heartbeat_uses_progress_round() { // 13a-3×13a-2 组合:静默心跳的轮次也吃 AiTaskProgress 快照 let out = stream_preview("", None, None, None, 3, Some(72), Some((15, None, Some(42)))); assert!(out.starts_with("💓 **仍在跑:第 15 轮 · 最近动作 `3 步工具调用` · 静默 72s**\n\n")); } #[test] fn heartbeat_due_threshold_and_bucket_dedup() { // 13a-2 判定纯函数:未达阈值不刷;达阈值且新桶刷;同静默分钟桶去重;进新桶再刷 assert_eq!(heartbeat_due(59, None, 60), None); assert_eq!(heartbeat_due(60, None, 60), Some(1)); assert_eq!(heartbeat_due(60, Some(1), 60), None); assert_eq!(heartbeat_due(119, Some(1), 60), None); // 119/60=1 同桶 assert_eq!(heartbeat_due(120, Some(1), 60), Some(2)); } // ── 38a 出站 md 净化 ── #[test] fn md_heading_to_bold_line() { assert_eq!(md_to_lark_friendly("## 分析报告"), "**分析报告**"); assert_eq!(md_to_lark_friendly("# 总览\n正文"), "**总览**\n正文"); // 非 CommonMark 形态(# 后无空格)不误伤 assert_eq!(md_to_lark_friendly("#hashtag 保留"), "#hashtag 保留"); } #[test] fn md_table_to_list_rows() { let md = "| 名称 | 数量 |\n|---|---|\n| 甲 | 1 |"; let out = md_to_lark_friendly(md); assert_eq!(out, "- **名称**:**数量**\n- 甲:1"); } #[test] fn md_fence_kept_verbatim() { // 围栏内 # 与表格语法原样保留(转换反伤代码可读性),围栏外正常净化 let md = "## 说明\n```rust\n# comment\n| a | b |\n```\n| x | y |\n|---|---|\n| 1 | 2 |"; let out = md_to_lark_friendly(md); assert!(out.starts_with("**说明**")); assert!(out.contains("```rust\n# comment\n| a | b |\n```")); assert!(out.contains("- **x**:**y**")); } #[test] fn md_plain_text_untouched_and_idempotent() { let md = "普通段落 **粗体** 保留\n- 列表项"; let once = md_to_lark_friendly(md); assert_eq!(once, md); assert_eq!(md_to_lark_friendly(&once), once); } // ── 22 受理时间戳过滤 ── #[test] fn acceptance_window_filters_old_messages() { // 有受理时刻:只认其后落库;边界时刻本身不算;缺时间戳的老消息视为不在边界内 assert!(msg_after_acceptance(Some(2000), Some(1000))); assert!(!msg_after_acceptance(Some(1000), Some(1000))); assert!(!msg_after_acceptance(Some(500), Some(1000))); assert!(!msg_after_acceptance(None, Some(1000))); // 无受理时刻不过滤(旧语义,调用方自担边界) assert!(msg_after_acceptance(Some(1), None)); assert!(msg_after_acceptance(None, None)); } // ── 34 推送终态判据 ── #[test] fn terminal_push_prefix_only() { assert!(is_terminal_push("✅ 完成")); assert!(is_terminal_push("❌ 失败:原因")); assert!(is_terminal_push(" ✅ 缩进也算")); 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")]) ); } }