优化: 飞书渠道文本链路与资源释放
This commit is contained in:
@@ -28,14 +28,20 @@ use crate::config::FeishuConfig;
|
|||||||
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
|
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FeishuAdapter {
|
pub struct FeishuAdapter {
|
||||||
client: LarkClient,
|
/// 常驻 client(&'static,构造期一次性 Box::leak)。D1(u-claw 教训:热重建不关旧
|
||||||
|
/// 连接→僵尸资源):leak 只在 new()/offline() 各发生一次;run() 重连重入只重建
|
||||||
|
/// Channel 不再重复 leak,防频繁断线场景 LarkClient 内存阶梯上涨。出站 dispatch
|
||||||
|
/// 与 Channel 构建共用同一份引用。
|
||||||
|
client: &'static LarkClient,
|
||||||
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
|
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
|
||||||
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeishuAdapter {
|
impl FeishuAdapter {
|
||||||
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
||||||
let client = LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?;
|
let client: &'static LarkClient = Box::leak(Box::new(
|
||||||
|
LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?,
|
||||||
|
));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
@@ -44,8 +50,11 @@ impl FeishuAdapter {
|
|||||||
|
|
||||||
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
|
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
|
||||||
pub fn offline() -> Self {
|
pub fn offline() -> Self {
|
||||||
let client = LarkClient::builder("offline", "offline").build()
|
let client: &'static LarkClient = Box::leak(Box::new(
|
||||||
.expect("占位凭证构造 LarkClient 不发网络请求,必成功");
|
LarkClient::builder("offline", "offline")
|
||||||
|
.build()
|
||||||
|
.expect("占位凭证构造 LarkClient 不发网络请求,必成功"),
|
||||||
|
));
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
@@ -68,16 +77,16 @@ impl PlatformAdapter for FeishuAdapter {
|
|||||||
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
|
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||||
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
|
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
|
||||||
let dispatcher = EventDispatcher::new("", "");
|
let dispatcher = EventDispatcher::new("", "");
|
||||||
let client = self.client.clone();
|
let client = self.client;
|
||||||
|
|
||||||
// 入站事件通道复制两份,分别给消息/卡片回调闭包
|
// 入站事件通道复制两份,分别给消息/卡片回调闭包
|
||||||
let events_for_msg = events.clone();
|
let events_for_msg = events.clone();
|
||||||
let events_for_card = events;
|
let events_for_card = events;
|
||||||
|
|
||||||
let channel: Channel<'static> = {
|
let channel: Channel<'static> = {
|
||||||
// Channel<'a> 借用 client;用 Box::leak 提升为 'static(适配器生命周期即进程生命周期)
|
// client 已是 &'static(构造期一次性 leak);run() 重连重入只重建 Channel,
|
||||||
let leaked: &'static LarkClient = Box::leak(Box::new(client));
|
// 不再重复 Box::leak(D1:防每次重入泄漏一份 LarkClient)
|
||||||
Channel::builder(leaked, dispatcher)
|
Channel::builder(client, dispatcher)
|
||||||
.policy(ChannelPolicy::default().require_mention(false))
|
.policy(ChannelPolicy::default().require_mention(false))
|
||||||
.on_message(move |msg| {
|
.on_message(move |msg| {
|
||||||
let events = events_for_msg.clone();
|
let events = events_for_msg.clone();
|
||||||
|
|||||||
+590
-26
@@ -6,12 +6,14 @@
|
|||||||
//! 会话复用 spawn_agentic_session 通用核心(pin_active=false,与编排节点同款调用方),
|
//! 会话复用 spawn_agentic_session 通用核心(pin_active=false,与编排节点同款调用方),
|
||||||
//! 完成通知走 AppState 通知桥(register/notify_agent_completion,编排节点先例)。
|
//! 完成通知走 AppState 通知桥(register/notify_agent_completion,编排节点先例)。
|
||||||
//!
|
//!
|
||||||
//! 第一批:文本收发(整段回复);流式 edit_text/审批卡片放第二批(内核留存于网关 feishu.rs)。
|
//! 第一批:文本收发(整段回复);流式预览已接入(订阅 ai_event_bus 的 AiTextDelta 节流刷占位卡片)。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use larksuite_oapi_sdk_rs::card::v1::{Card, CardDocument, Element, Markdown as MdElement};
|
||||||
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy, SendInput};
|
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy, SendInput};
|
||||||
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
|
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -47,9 +49,16 @@ fn norm(raw: Option<String>) -> String {
|
|||||||
struct Ctx {
|
struct Ctx {
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
client: LarkClient,
|
client: LarkClient,
|
||||||
whitelist: Vec<String>,
|
/// 白名单缓存(D2 热加载):每消息现读 KV 刷新,读取失败沿用本缓存,改配置免重启
|
||||||
|
whitelist: Arc<tokio::sync::RwLock<Vec<String>>>,
|
||||||
/// chat_id → conversation_id(启动时从 KV 恢复,变更回写)
|
/// chat_id → conversation_id(启动时从 KV 恢复,变更回写)
|
||||||
chat_map: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
chat_map: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
||||||
|
/// per-chat 串行锁:同会话消息排队处理,防通知桥单槽跨消息串扰(P1-1)
|
||||||
|
chat_locks: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
|
||||||
|
/// D3 活性时间戳(unix 秒):入站事件与连接建立成功时更新,看门狗据此判事件断流
|
||||||
|
last_alive: Arc<AtomicI64>,
|
||||||
|
/// D3 在途会话数:spawn 到投递完成期间 >0,看门狗要求为 0 才告警,防有回复待投递时误报
|
||||||
|
inflight: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
||||||
@@ -58,22 +67,38 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
|
|||||||
let db = app.state::<crate::state::AppState>().db.clone();
|
let db = app.state::<crate::state::AppState>().db.clone();
|
||||||
let repo = SettingsRepo::new(&db);
|
let repo = SettingsRepo::new(&db);
|
||||||
let raw = norm(repo.get(KV_FEISHU_CONFIG).await.ok().flatten());
|
let raw = norm(repo.get(KV_FEISHU_CONFIG).await.ok().flatten());
|
||||||
|
// 早退点全部补 feishu_log:tracing 只进控制台,用户排障只看得到 feishu-channel.log,
|
||||||
|
// 四个早退原因各有不同,不落盘则"渠道未连接"完全盲查(2026-10-05 排查实证)
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
tracing::info!("[feishu] feishu-config 未配置,渠道不启动");
|
tracing::info!("[feishu] feishu-config 未配置,渠道不启动");
|
||||||
|
feishu_log("启动早退: feishu-config 未配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(cfg) = serde_json::from_str::<FeishuConfig>(&raw) else {
|
let cfg = match serde_json::from_str::<FeishuConfig>(&raw) {
|
||||||
tracing::error!("[feishu] feishu-config 解析失败,渠道不启动");
|
Ok(c) => c,
|
||||||
return;
|
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() {
|
if !cfg.enabled || cfg.app_id.is_empty() || cfg.app_secret.is_empty() {
|
||||||
tracing::info!("[feishu] enabled=false 或凭证缺失,渠道不启动");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let client = match LarkClient::builder(&cfg.app_id, &cfg.app_secret).build() {
|
let client = match LarkClient::builder(&cfg.app_id, &cfg.app_secret).build() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[feishu] LarkClient 构造失败: {e}");
|
tracing::error!("[feishu] LarkClient 构造失败: {e}");
|
||||||
|
feishu_log(&format!("启动早退: LarkClient 构造失败: {e}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -85,43 +110,129 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
|
|||||||
let ctx = Arc::new(Ctx {
|
let ctx = Arc::new(Ctx {
|
||||||
app,
|
app,
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
whitelist: cfg.whitelist,
|
whitelist: Arc::new(tokio::sync::RwLock::new(cfg.whitelist)),
|
||||||
chat_map: Arc::new(tokio::sync::Mutex::new(restored)),
|
chat_map: Arc::new(tokio::sync::Mutex::new(restored)),
|
||||||
|
chat_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
|
last_alive: Arc::new(AtomicI64::new(now_secs())),
|
||||||
|
inflight: Arc::new(AtomicUsize::new(0)),
|
||||||
});
|
});
|
||||||
if let Err(e) = run_loop(ctx, client).await {
|
// D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都
|
||||||
tracing::error!("[feishu] 长连接退出: {e}");
|
// Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用
|
||||||
|
// 引用,重连循环内复用同一 leaked 引用,只重建 Channel/dispatcher
|
||||||
|
let leaked: &'static LarkClient = Box::leak(Box::new(client));
|
||||||
|
// 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死
|
||||||
|
let mut backoff = 2u64;
|
||||||
|
loop {
|
||||||
|
if let Err(e) = run_loop(ctx.clone(), leaked).await {
|
||||||
|
tracing::error!("[feishu] 长连接退出,{}s 后重连: {e}", backoff);
|
||||||
|
feishu_log(&format!("长连接断开,{}s 后重连: {e}", backoff));
|
||||||
|
} else {
|
||||||
|
tracing::info!("[feishu] 长连接正常退出,{}s 后重连", backoff);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
|
||||||
|
backoff = (backoff * 2).min(300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_loop(ctx: Arc<Ctx>, client: LarkClient) -> anyhow::Result<()> {
|
async fn run_loop(ctx: Arc<Ctx>, client: &'static LarkClient) -> anyhow::Result<()> {
|
||||||
let dispatcher = EventDispatcher::new("", "");
|
let dispatcher = EventDispatcher::new("", "");
|
||||||
let ctx_msg = ctx.clone();
|
let ctx_msg = ctx.clone();
|
||||||
// Channel<'a> 借用 client;Box::leak 提升为 'static(渠道生命周期即进程生命周期)
|
let ctx_ready = ctx.clone();
|
||||||
let leaked: &'static LarkClient = Box::leak(Box::new(client));
|
// D3 看门狗与单次连接生命周期绑定:run_loop 退出时 abort,不逐重连累积
|
||||||
let channel: Channel<'static> = Channel::builder(leaked, dispatcher)
|
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))
|
.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| {
|
.on_message(move |msg| {
|
||||||
let ctx = ctx_msg.clone();
|
let ctx = ctx_msg.clone();
|
||||||
async move {
|
async move {
|
||||||
handle_message(ctx, msg).await;
|
// 异步处理:回调内绝不等 agent 跑完(单消息长任务会阻塞事件泵,
|
||||||
|
// 2026-10-05 16:18 实证:rx 等待卡死后全部后续消息黑洞)
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
handle_message(ctx, msg).await;
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
||||||
channel
|
let result = channel
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?;
|
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"));
|
||||||
|
watchdog.abort();
|
||||||
|
result?;
|
||||||
Ok(())
|
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<Ctx>) {
|
||||||
|
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<AtomicUsize>);
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
|
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
|
||||||
async fn handle_message(
|
async fn handle_message(
|
||||||
ctx: Arc<Ctx>,
|
ctx: Arc<Ctx>,
|
||||||
msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage,
|
msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage,
|
||||||
) {
|
) {
|
||||||
|
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
|
||||||
|
ctx.last_alive.store(now_secs(), Ordering::SeqCst);
|
||||||
if msg.message_type != "text" {
|
if msg.message_type != "text" {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,10 +240,13 @@ async fn handle_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
||||||
|
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
|
||||||
|
let text = strip_mention_placeholders(&text);
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let chat_id = msg.chat_id.clone();
|
let chat_id = msg.chat_id.clone();
|
||||||
|
let message_id = msg.message_id.clone();
|
||||||
let open_id = msg
|
let open_id = msg
|
||||||
.sender
|
.sender
|
||||||
.user_id
|
.user_id
|
||||||
@@ -140,10 +254,12 @@ async fn handle_message(
|
|||||||
.and_then(|u| u.open_id.clone())
|
.and_then(|u| u.open_id.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// 白名单:空=放行;非空且不在内 → 提示并附自己的 open_id(首启自助回填,免翻日志)
|
// 白名单(D2 热加载):空=放行;非空且不在内 → 提示并附自己的 open_id(首启自助回填,
|
||||||
if !ctx.whitelist.is_empty() && !ctx.whitelist.iter().any(|w| w == &open_id) {
|
// 免翻日志)。每消息现读 KV,设置面板改白名单即生效免重启
|
||||||
|
let whitelist = read_whitelist(&ctx).await;
|
||||||
|
if !whitelist.is_empty() && !whitelist.iter().any(|w| w == &open_id) {
|
||||||
let hint = format!("⛔ 你不在白名单内。你的 open_id:`{open_id}`(发给管理员回填后重试)");
|
let hint = format!("⛔ 你不在白名单内。你的 open_id:`{open_id}`(发给管理员回填后重试)");
|
||||||
send_text(&ctx.client, &chat_id, &hint).await;
|
reply_message(&ctx.client, &message_id, &chat_id, &hint).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,20 +269,51 @@ async fn handle_message(
|
|||||||
let db = ctx.app.state::<crate::state::AppState>().db.clone();
|
let db = ctx.app.state::<crate::state::AppState>().db.clone();
|
||||||
let snapshot = ctx.chat_map.lock().await.clone();
|
let snapshot = ctx.chat_map.lock().await.clone();
|
||||||
save_chat_map(&db, &snapshot).await;
|
save_chat_map(&db, &snapshot).await;
|
||||||
send_text(&ctx.client, &chat_id, "✅ 已重置会话").await;
|
reply_message(&ctx.client, &message_id, &chat_id, "✅ 已重置会话").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// chat_id → conv_id 映射(内存命中优先,未命中新建)
|
// chat_id → conv_id 映射(内存命中优先,未命中新建)
|
||||||
|
feishu_log(&format!("收到消息 chat={chat_id} open_id={open_id} text={}", truncate(&text, 80)));
|
||||||
|
// 立即确认(交互丝滑):占位改用单行卡片并记下其 message_id,完成后原地更新为结果,
|
||||||
|
// 占位与结果合一不刷屏;占位 reply 失败则置空,完成路径回退现状(新发卡片)
|
||||||
|
let placeholder_id =
|
||||||
|
reply_markdown_card(&ctx.client, &message_id, "🤔 已接收,处理中…").await;
|
||||||
|
// 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 conv_id = {
|
||||||
let mut map = ctx.chat_map.lock().await;
|
let mut map = ctx.chat_map.lock().await;
|
||||||
map.entry(chat_id.clone()).or_insert_with(new_id).clone()
|
map.entry(chat_id.clone()).or_insert_with(new_id).clone()
|
||||||
};
|
};
|
||||||
|
feishu_log(&format!("spawn 会话 conv={conv_id}"));
|
||||||
|
|
||||||
|
// D3 在途计数:从此到投递完成期间连接假活判定须让位,RAII 保任意退出路径回落
|
||||||
|
ctx.inflight.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let _inflight_guard = InflightGuard(ctx.inflight.clone());
|
||||||
let state = ctx.app.state::<crate::state::AppState>();
|
let state = ctx.app.state::<crate::state::AppState>();
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||||
state.register_agent_completion(conv_id.clone(), tx);
|
state.register_agent_completion(conv_id.clone(), tx);
|
||||||
|
|
||||||
|
// 流式预览:订阅既有 ai_event_bus 拿本 conv 的 AiTextDelta 增量(emit_ai_chat_event
|
||||||
|
// 双写总线已接入,渠道侧纯消费,零改动接线)。spawn 前订阅防早期增量丢失;
|
||||||
|
// 终版文本经 deliver_tx 也交给本任务单点投递,占位卡片自 spawn 起只被这一个任务写,
|
||||||
|
// 中间态与终版写卡天然串行,无迟到中间态覆盖终版的竞态
|
||||||
|
let (deliver_tx, deliver_rx) = tokio::sync::oneshot::channel::<String>();
|
||||||
|
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,
|
||||||
|
deliver_rx,
|
||||||
|
));
|
||||||
|
|
||||||
match crate::commands::ai::commands::chat::spawn_agentic_session(
|
match crate::commands::ai::commands::chat::spawn_agentic_session(
|
||||||
&ctx.app,
|
&ctx.app,
|
||||||
&state,
|
&state,
|
||||||
@@ -189,18 +336,294 @@ async fn handle_message(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 启动失败:通知桥条目清掉防死条目(编排节点同款)
|
// 启动失败:通知桥条目清掉防死条目(编排节点同款)
|
||||||
state.notify_agent_completion(&conv_id, String::new());
|
state.notify_agent_completion(&conv_id, String::new());
|
||||||
send_text(&ctx.client, &chat_id, &format!("❌ 会话启动失败: {e}")).await;
|
// 关终版通道让流式任务退出(此路无任何增量产出,卡片未被流式写过),
|
||||||
|
// 待其退出后再走失败投递,保卡片写入单点次序
|
||||||
|
drop(deliver_tx);
|
||||||
|
let _ = stream_task.await;
|
||||||
|
feishu_log(&format!("spawn 失败 conv={conv_id}: {e}"));
|
||||||
|
deliver_result(
|
||||||
|
&ctx.client,
|
||||||
|
&placeholder_id,
|
||||||
|
&message_id,
|
||||||
|
&chat_id,
|
||||||
|
"❌ 会话启动失败,请稍后重试",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等最终回复(通知桥 oneshot;超时兜底防悬挂)
|
// 等最终回复(通知桥 oneshot;超时 30 分钟,任务仍在后台跑不丢失)
|
||||||
let reply = match tokio::time::timeout(std::time::Duration::from_secs(600), rx).await {
|
let reply = match tokio::time::timeout(std::time::Duration::from_secs(1800), rx).await {
|
||||||
Ok(Ok(text)) if !text.trim().is_empty() => text,
|
Ok(Ok(text)) if !text.trim().is_empty() => text,
|
||||||
Ok(_) => "(会话已结束,无文本回复)".to_string(),
|
// A 韧性兜底:通知桥带回空内容 → 回查 DB 该会话最后一条 assistant(事实源),有则照发
|
||||||
Err(_) => "⏱ 回复超时,请重试或 /new 重置".to_string(),
|
_ => {
|
||||||
|
let db = ctx.app.state::<crate::state::AppState>().db.clone();
|
||||||
|
let fallback = last_assistant_reply(&db, &conv_id).await;
|
||||||
|
feishu_log(&format!("conv={conv_id} 通知桥空/超时,DB回查={}", truncate(fallback.as_deref().unwrap_or("无"), 60)));
|
||||||
|
match fallback {
|
||||||
|
Some(text) => text,
|
||||||
|
None => "⚠️ 本轮没有生成文本回复(已记录排查)。任务若仍在进行可稍后 /new 重置或重发。".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
send_text(&ctx.client, &chat_id, &reply).await;
|
feishu_log(&format!("回复 chat={chat_id} len={}", reply.len()));
|
||||||
|
// 终版投递:交流式任务单点执行(内部复用 deliver_result 原回退链);
|
||||||
|
// 任务已死(通道已关或异常退出)则兜底直投,此时无并发写卡方,回退链行为不变;
|
||||||
|
// 若任务在投递后异常退出,重复一次终版 edit_card 内容相同,无副作用
|
||||||
|
let delivered = match deliver_tx.send(reply.clone()) {
|
||||||
|
Ok(()) => matches!(stream_task.await, Ok(())),
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
if !delivered {
|
||||||
|
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &reply).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D2 白名单热加载:每消息现读 KV,设置面板改白名单即生效免重启;读取/解析失败沿用
|
||||||
|
/// 上次缓存——拒收口径随缓存,不因 DB 抖动放大为误放行;成功时回写缓存供降级使用
|
||||||
|
async fn read_whitelist(ctx: &Ctx) -> Vec<String> {
|
||||||
|
let db = ctx.app.state::<crate::state::AppState>().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::<FeishuConfig>(&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;
|
||||||
|
|
||||||
|
/// 流式预览任务:消费 ai_event_bus 的 AiTextDelta,节流把累计文本 edit_card 进占位卡片;
|
||||||
|
/// 终版文本由 deliver_tx 单点传入,复用 deliver_result 投递后退出。
|
||||||
|
///
|
||||||
|
/// 竞态设计:占位卡片在本任务存活期只有本任务写,中间态 tick 与终版投递同任务串行,
|
||||||
|
/// 上一次 edit_card 的 HTTP 响应返回后才可能发下一次,飞书服务端所见次序即代码次序,
|
||||||
|
/// 终版不会被迟到的中间态覆盖,故无需 AbortHandle。中间态刷失败不新发卡片回退
|
||||||
|
/// (不值得为预览刷屏),新发回退链仍归终版 deliver_result 独有,行为不受本任务影响。
|
||||||
|
async fn run_stream_refresher(
|
||||||
|
ctx: Arc<Ctx>,
|
||||||
|
conv_id: String,
|
||||||
|
placeholder_id: Option<String>,
|
||||||
|
message_id: String,
|
||||||
|
chat_id: String,
|
||||||
|
mut delta_rx: tokio::sync::broadcast::Receiver<serde_json::Value>,
|
||||||
|
mut deliver_rx: tokio::sync::oneshot::Receiver<String>,
|
||||||
|
) {
|
||||||
|
let mut acc = String::new();
|
||||||
|
// 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额
|
||||||
|
let mut acc_chars = 0usize;
|
||||||
|
let mut painted_chars = 0usize;
|
||||||
|
let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL);
|
||||||
|
// 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// 终版单点收口:投递后任务退出,卡片所有权移交终版内容
|
||||||
|
final_text = &mut deliver_rx => {
|
||||||
|
if let Ok(text) = final_text {
|
||||||
|
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
|
||||||
|
}
|
||||||
|
// 发送端被丢弃(spawn 失败路径)时不投递,由调用方兜底
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
item = delta_rx.recv() => match item {
|
||||||
|
Ok(v) => {
|
||||||
|
// 双过滤:只要本 conv 的流式增量;type tag 即变体名(AiChatEvent 无 serde rename)
|
||||||
|
if v.get("type").and_then(|t| t.as_str()) != Some("AiTextDelta") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) {
|
||||||
|
acc.push_str(delta);
|
||||||
|
acc_chars += delta.chars().count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 预览允许丢帧:终版事实源在通知桥 oneshot,消费落后只降级预览不断链路
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)");
|
||||||
|
}
|
||||||
|
// 总线随 AppState 常驻理论不关;防御性收口:只等终版,不再消费增量
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
if let Ok(text) = deliver_rx.await {
|
||||||
|
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ = ticker.tick() => {
|
||||||
|
// 占位缺失(首张卡片 reply 失败)时无从原地刷,保持静默等终版走新发回退
|
||||||
|
let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if acc_chars == painted_chars {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if update_card(&ctx.client, pid, &stream_preview(&acc)).await {
|
||||||
|
painted_chars = acc_chars;
|
||||||
|
}
|
||||||
|
// 刷失败静默跳过,下个 tick 自然重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式中间态文本:尾部加光标符示意仍在生成;超上限从头截断保尾部,前缀标注省略事实
|
||||||
|
/// 防读者误把中间态当全文
|
||||||
|
fn stream_preview(acc: &str) -> String {
|
||||||
|
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!("{body}\n▌")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// markdown 回复:interactive 卡片承载(lark_md 原生渲染标题/列表/粗体,text 类型会符号裸露)
|
||||||
|
async fn send_markdown_card(client: &LarkClient, message_id: &str, chat_id: &str, text: &str) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 sent = if !message_id.is_empty() {
|
||||||
|
client.channel_messaging().reply(message_id, &input, opt).await.is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if !sent {
|
||||||
|
if let Err(e) = client.channel_messaging().send(&input, opt).await {
|
||||||
|
tracing::error!("[feishu] 卡片发送失败: {e}");
|
||||||
|
// 卡片彻底失败仍回退纯文本,保可达
|
||||||
|
reply_message(client, message_id, chat_id, text).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reply 一张 markdown 卡片,成功返回卡片消息 id(完成后原地更新用)
|
||||||
|
/// (SDK strict reply 内部已校验业务码,Ok 即业务成功;id 为空视为失败)
|
||||||
|
async fn reply_markdown_card(client: &LarkClient, message_id: &str, text: &str) -> Option<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结果投递统一入口:先做表格降级(md 表格在卡片 markdown 组件渲染不稳),
|
||||||
|
/// 优先原地更新占位卡片(占位与结果合一);占位缺失或更新失败回退新发卡片(内含纯文本兜底)
|
||||||
|
async fn deliver_result(
|
||||||
|
client: &LarkClient,
|
||||||
|
placeholder_id: &Option<String>,
|
||||||
|
message_id: &str,
|
||||||
|
chat_id: &str,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
let text = clip_for_card(&downgrade_md_tables(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("占位卡片更新失败,回退新发卡片");
|
||||||
|
}
|
||||||
|
send_markdown_card(client, message_id, chat_id, &text).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡片正文长度防御:飞书卡片 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) {
|
async fn send_text(client: &LarkClient, chat_id: &str, text: &str) {
|
||||||
@@ -214,8 +637,149 @@ async fn send_text(client: &LarkClient, chat_id: &str, text: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 剥离飞书 @ 占位串(如 @_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<String> = 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('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分隔行形态:| 开头,且整行只由 | - : 与空白构成并含至少一段 ---
|
||||||
|
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<String> = 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(":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键事件日志落盘(零依赖 fs append;Roaming 下 feishu-channel.log,排查空回复等复现凭据)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回查该会话最后一条非空 assistant 文本(通知桥空内容时的事实源兜底)
|
||||||
|
async fn last_assistant_reply(db: &df_storage::db::Database, conv_id: &str) -> Option<String> {
|
||||||
|
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())
|
||||||
|
.map(|m| m.content)
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, String>) {
|
async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, String>) {
|
||||||
let repo = SettingsRepo::new(db);
|
let repo = SettingsRepo::new(db);
|
||||||
let json = serde_json::to_string(map).unwrap_or_default();
|
let json = serde_json::to_string(map).unwrap_or_default();
|
||||||
let _ = repo.set(KV_CHAT_MAP, &json).await;
|
let _ = repo.set(KV_CHAT_MAP, &json).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::strip_mention_placeholders;
|
||||||
|
|
||||||
|
#[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"), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user