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

- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
This commit is contained in:
lxy
2026-10-15 14:27:22 +08:00
parent 7548896f6d
commit 795935bf6a
10 changed files with 395 additions and 79 deletions
+3
View File
@@ -310,6 +310,7 @@ mod tests {
role: MessageRole::User, role: MessageRole::User,
content: "字面量构造".into(), content: "字面量构造".into(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
@@ -368,6 +369,7 @@ mod tests {
role: MessageRole::Assistant, role: MessageRole::Assistant,
content: "answer".to_string(), content: "answer".to_string(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
@@ -429,6 +431,7 @@ mod tests {
role: MessageRole::Assistant, role: MessageRole::Assistant,
content: "answer".to_string(), content: "answer".to_string(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
+63 -1
View File
@@ -527,6 +527,31 @@ pub(crate) async fn run_agentic_loop(
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。 // 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations; conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0; conv.round_budget_extra = 0;
// 真目标源(2026-10-15 目标语义重构):pinned_goals 从「工具推理流水账」改为「用户任务原文」
// ——流水账模式下用户真实意图从不进目标体系,压缩/重启后 AI 即失忆(2026-10-12/15
// 两次丢目标事故同根)。首条任务型 user 消息写入首位;已有目标不覆盖(尊重
// UPDATE_GOALS 显式修正);[系统恢复] 引导与超短碎片不入(递归套娃教训)。
if conv.pinned_goals.is_empty() {
let task_text = conv
.messages
.iter()
.find(|m| {
matches!(m.role, df_ai::provider::MessageRole::User)
&& !m.content.starts_with("[系统恢复]")
&& m.content.chars().count() >= 4
})
.map(|m| {
let mut t: String = m.content.chars().take(GOAL_MAX_CHARS).collect();
if m.content.chars().count() > GOAL_MAX_CHARS {
t.push('…');
}
t
});
if let Some(t) = task_text {
conv.pinned_goals.push(super::GoalEntry::new(t));
tracing::info!(conv_id = %conv_id, "[ai] 会话目标取首条任务消息(目标语义重构)");
}
}
( (
conv.stop_flag.clone(), conv.stop_flag.clone(),
conv.notify.clone(), conv.notify.clone(),
@@ -2013,6 +2038,9 @@ async fn prepare_round_inputs(
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态 // 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() { if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly); session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
// 本轮请求已含截至此刻的全部消息,注入计数清零(=已消化);此后到收敛前
// 再到的注入会计数,收敛出口凭 >0 续一轮消化(见收敛分支守卫)。
session.conv(conv_id).pending_injects = 0;
} }
let conv = match session.conv_read(conv_id) { let conv = match session.conv_read(conv_id) {
Some(c) => c, Some(c) => c,
@@ -2218,7 +2246,10 @@ async fn finalize_round_output(
}) })
.collect(); .collect();
} else { } else {
update_pinned_goals(&mut session, conv_id, tool_calls_acc); // 工具推理流水账停写(2026-10-15 目标语义重构):「执行 xxx/查看 xxx」是手段记录
// 不是用户目标,入 pinned_goals 挤占真目标且压缩/重启后即失忆;工具轨迹已由
// 轨迹卡与审计表完整覆盖。目标来源收敛为:首条任务消息(入口写入)+UPDATE_GOALS
// 显式修正(上方分支,LLM 显式目标管理继续有效)。
} }
} }
} }
@@ -2345,6 +2376,35 @@ async fn finalize_round_output(
// 正常收敛(loop 主体置 converged 后 break) // 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息 // P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。 // (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
// 注入未消化守卫(2026-10-14 实测):本轮组装后到达的补充指令,纯问答轮一轮收敛即
// 无人消费(本轮上下文已在生成中定型)。计数>0 → 插提示续一轮,下轮
// build_for_request 自然带上补充内容;计数已 take 清零,同批注入不重复触发。
let pending_injects = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
std::mem::take(&mut session.conv(conv_id).pending_injects)
} else {
0
}
};
if pending_injects > 0 {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut tip = ChatMessage::system(format!(
"用户在回答生成期间补充了 {pending_injects} 条消息(见对话记录中的补充指令);\
请结合补充内容继续或修正上面的回答,勿忽略。"
));
tip.model = Some(resolved_model.to_string());
conv.messages.push(tip);
}
tracing::info!(
conv_id = %conv_id, pending = pending_injects,
"[ai] 收敛时检出未消化注入,插提示续一轮消化"
);
// 不 return Converged:掉到函数尾部 RunTools(本轮无工具空转),
// loop 顶部正常续轮,补充指令获得完整消费轮。
} else {
if round_truncated_by_max_tokens { if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) { if session.per_conv.contains_key(conv_id) {
@@ -2360,6 +2420,7 @@ async fn finalize_round_output(
return RoundEpilogue::Converged; return RoundEpilogue::Converged;
} }
} }
}
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。 // 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里, // 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
@@ -3602,6 +3663,7 @@ fn push_assistant_message(
// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ── // ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ──
// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。 // 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。
// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。 // 入参 session 需外层调用方持锁;纯内存 mutate,无 await。
#[allow(dead_code)]
fn update_pinned_goals( fn update_pinned_goals(
session: &mut AiSession, session: &mut AiSession,
conv_id: &str, conv_id: &str,
@@ -1197,6 +1197,9 @@ pub(crate) async fn inject_runtime_message(
if extended { if extended {
conv.round_budget_extra += 1; conv.round_budget_extra += 1;
} }
// 未消化计数(2026-10-14 实测):纯问答轮一轮收敛无下一轮,末轮扩轮机制(loop_max>0 才生效)
// 兜不住;收敛出口凭此计数感知「组装后有注入到达」并续一轮消化。
conv.pending_injects = conv.pending_injects.saturating_add(1);
drop(session); drop(session);
// 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉 // 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await; save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
@@ -1204,6 +1207,20 @@ pub(crate) async fn inject_runtime_message(
Ok(true) Ok(true)
} }
/// 桌面端补充注入入口(桌面 AiChat 运行中发消息的 u-claw 语义:补充并进当前回答,不开新轮)。
/// 把 inject_runtime_message 暴露为 Tauri command,与飞书渠道(feishu_channel.rs)同源同一实现,
/// 锁纪律/末轮顺延语义收敛在 inject_runtime_message 单一真值,本 command 纯透传零附加逻辑。
/// 返回契约:Ok(true)=已注入(前端不入队,提示已并入当前回答);Ok(false)=会话不存在
/// (前端按普通新消息入队);Err=非活跃/Stopping(前端零破坏回退常规入队)。
#[tauri::command]
pub async fn ai_inject_runtime(
state: State<'_, AppState>,
conversation_id: String,
message: String,
) -> Result<bool, String> {
inject_runtime_message(&state, &conversation_id, &message).await
}
/// 批准/拒绝挂起的工具调用 /// 批准/拒绝挂起的工具调用
#[tauri::command] #[tauri::command]
pub async fn ai_approve( pub async fn ai_approve(
+5
View File
@@ -1065,6 +1065,10 @@ pub struct PerConvState {
/// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。 /// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。
/// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。 /// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。
pub round_budget_extra: usize, pub round_budget_extra: usize,
/// 未消化注入计数(2026-10-14 实测):inject_runtime_message 每次 +1;每轮 build_for_request
/// 组装后清零(消息已进本轮请求=消化);收敛出口查 >0 则不收敛续一轮,治「纯问答轮
/// 一轮收敛,生成期间到达的补充指令无人消费只能等下次请求」的丢失。内存态不落库。
pub pending_injects: u32,
/// 当前 agent 循环的语言设置(用于审批后恢复循环) /// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>, pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override) /// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
@@ -1266,6 +1270,7 @@ impl PerConvState {
iteration_used: 0, iteration_used: 0,
loop_max_rounds: 0, loop_max_rounds: 0,
round_budget_extra: 0, round_budget_extra: 0,
pending_injects: 0,
agent_language: None, agent_language: None,
model_override: None, model_override: None,
session_trust: HashSet::new(), session_trust: HashSet::new(),
+216 -61
View File
@@ -384,6 +384,46 @@ const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查
/// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存 /// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; 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<Vec<u8>, 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 引用化存储)。 /// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。
/// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀), /// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀),
/// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。 /// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。
@@ -396,53 +436,20 @@ async fn download_feishu_image(
message_id: &str, message_id: &str,
file_key: &str, file_key: &str,
) -> Result<ContentPart, String> { ) -> Result<ContentPart, String> {
// 下载带防线(对齐 u-claw fetchResource 经验):3 次指数退避(1s/4s)+单次 120s 超时 let data = download_message_resource_with_retry(client, message_id, file_key, "image", "图片")
// ——飞书资源接口偶发抖动,无重试时用户收到一次性失败;超时防接口挂起拖死受理链。 .await?;
// 空响应与错误同待遇进重试(可能为服务端瞬时异常),耗尽后报最后一次原因 if data.len() > MAX_IMAGE_BYTES {
let resp = {
let mut last_err = String::from("下载失败: 未知错误");
let mut out = None;
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,
"image",
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => {
out = Some(r);
break;
}
Ok(Ok(_)) => last_err = "图片内容为空".into(),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
match out {
Some(r) => r,
None => return Err(last_err),
}
};
if resp.data.len() > MAX_IMAGE_BYTES {
return Err(format!( return Err(format!(
"图片超过 10MB 上限(约 {}MB),请压缩后重发", "图片超过 10MB 上限(约 {}MB),请压缩后重发",
resp.data.len() / 1024 / 1024 data.len() / 1024 / 1024
)); ));
} }
let media_type = sniff_image_mime(&resp.data) let media_type = sniff_image_mime(&data)
.ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?; .ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?;
// 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析), // 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析),
// 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。 // 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。
// 飞书 image 消息无原始文件名(None=纯 hash 名) // 飞书 image 消息无原始文件名(None=纯 hash 名)
let url = store.save(&resp.data, media_type, None).await?; let url = store.save(&data, media_type, None).await?;
Ok(ContentPart::Image { Ok(ContentPart::Image {
url: Some(url), url: Some(url),
base64: None, base64: None,
@@ -451,6 +458,67 @@ async fn download_feishu_image(
}) })
} }
/// 文件消息喂模型的任务语境:告知文件名与本机落盘路径,引导用 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<String> {
(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=纯本地模式) /// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式)
async fn read_media_oss_config( async fn read_media_oss_config(
db: &df_storage::db::Database, db: &df_storage::db::Database,
@@ -588,12 +656,21 @@ async fn handle_message(
) { ) {
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳 // D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
ctx.last_alive.store(now_secs(), Ordering::SeqCst); ctx.last_alive.store(now_secs(), Ordering::SeqCst);
// 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart); // 群聊响应门控对齐 u-claw 模式(feishu_events.go mentionedBot 四条):①@自己响应
// 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入 // ②@所有人响应 ③@别的bot不响应 ④无mention静默旁观;私聊恒响应。SDK 的
// msg.resources(ChannelResource),无需自行解析 content JSON。 // mentioned_bot/mention_all 已承载①②③的精确判定。门控必须在类型分流之前——
let image_key = if msg.message_type == "image" { // 分流段对语音等未支持类型会回提示卡,门控放后面时未艾特的群消息也会得到回复
// (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") { match msg.resources.iter().find(|r| r.resource_type == "image") {
Some(r) => Some(r.file_key.clone()), Some(r) => (Some(r.file_key.clone()), None),
None => { None => {
let _ = reply_markdown_card( let _ = reply_markdown_card(
&ctx.client, &ctx.client,
@@ -604,26 +681,39 @@ async fn handle_message(
return; return;
} }
} }
} else if msg.message_type != "text" { } else if msg.message_type == "file" {
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。 match msg.resources.iter().find(|r| r.resource_type == "file") {
// 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述 Some(r) => (None, Some(r.clone())),
None => {
let _ = reply_markdown_card( let _ = reply_markdown_card(
&ctx.client, &ctx.client,
&msg.message_id, &msg.message_id,
"📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。", "⚠️ 文件消息缺少 file_key,无法处理。",
)
.await;
return;
}
}
} else if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡,不再零反馈。文本/图片/文件已支持,
// 余下语音等类型如实告知规划中
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂支持文本、图片与文件消息,语音等其他类型规划中。",
) )
.await; .await;
return; return;
} else { } else {
None (None, None)
}; };
if msg.chat_type == "group" && !msg.mentioned_bot { // 附件消息不带文字(飞书 image/file 均为独立消息类型),msg.text 的图链/文件标签
return; // 对模型无意义,不喂:图片喂固定引导词交代任务语境;文件语境需落盘路径,先预填
} // 文件名占位版(命令/审批判定恒不命中),下载点覆写为含路径终版
// 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境; let mut text = if image_key.is_some() {
// msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂
let text = if image_key.is_some() {
IMAGE_INBOUND_PROMPT.to_string() IMAGE_INBOUND_PROMPT.to_string()
} else if let Some(res) = &file_res {
file_inbound_prompt(res.file_name.trim(), None)
} else { } else {
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 承载,占位串只污染文本 // 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
@@ -685,6 +775,30 @@ async fn handle_message(
None => None, None => None,
}; };
// 文件入站:下载消息资源→落盘拿本机路径→覆写任务语境终版(含路径,模型 read_file
// 按需读取)。与图片同段白名单后下载(未授权不烧流量)、占位发票前早退(失败回
// 可读错误卡,超 20MB 话术在下载错误内)
if let Some(res) = &file_res {
let state = ctx.app.state::<crate::state::AppState>();
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 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。 // 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。
// 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充 // 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充
// 指令注入任务;未知命令词落回常规任务路径,零行为变更 // 指令注入任务;未知命令词落回常规任务路径,零行为变更
@@ -836,9 +950,10 @@ async fn handle_message(
let state = ctx.app.state::<crate::state::AppState>(); let state = ctx.app.state::<crate::state::AppState>();
let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned(); let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned();
if let Some(cid) = conv_id_now { if let Some(cid) = conv_id_now {
// 图片消息不注入:inject_runtime_message 只收文本,注入则图被丢、模型只见 // 附件消息(图片/文件)不注入,走与图片相同的后续链:inject_runtime_message
// 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发) // 只收文本,图片注入则图被丢、模型只见引导词空跑;文件作为独立新任务语义
if image_key.is_none() && state.conv_states.is_active(&cid) { // 同样应过受理/防抖/发票链,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 { match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await {
Ok(true) => { Ok(true) => {
let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await; let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await;
@@ -2402,9 +2517,10 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
mod tests { mod tests {
use super::{ use super::{
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type, ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
heartbeat_due, is_terminal_push, md_to_lark_friendly, merge_debounce_parts, file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
merge_debounce_text, msg_after_acceptance, parse_command, sniff_image_mime, is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
strip_mention_placeholders, stream_preview, msg_after_acceptance, parse_command, sniff_image_mime, strip_mention_placeholders,
stream_preview,
}; };
#[test] #[test]
@@ -2453,6 +2569,45 @@ mod tests {
assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None); 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_<hash 前 8>,不同内容回退名不同
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] #[test]
fn command_case_and_tail_tolerance() { fn command_case_and_tail_tolerance() {
// 30:/new 大小写与附言容错,附言不烧 LLM // 30:/new 大小写与附言容错,附言不烧 LLM
+2
View File
@@ -496,6 +496,8 @@ pub fn run() {
commands::ai::ai_pending_tool_calls, commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear, commands::ai::ai_chat_clear,
commands::ai::ai_is_generating, commands::ai::ai_is_generating,
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
commands::ai::ai_inject_runtime,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩 // F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context, commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context, commands::ai::ai_chat_compress_context,
+47 -7
View File
@@ -98,6 +98,20 @@ impl MediaStore {
media_type: &str, media_type: &str,
original_name: Option<&str>, original_name: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
self.save_with_path(bytes, media_type, original_name)
.await
.map(|(url, _)| url)
}
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
pub async fn save_with_path(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<(String, std::path::PathBuf), String> {
let key = content_key(bytes, media_type, original_name); let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media"); let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir) tokio::fs::create_dir_all(&dir)
@@ -112,26 +126,33 @@ impl MediaStore {
return Err(format!("媒体写入失败: {e}")); return Err(format!("媒体写入失败: {e}"));
} }
} }
match &self.oss { let url = match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await { Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)), Ok(()) => public_url(cfg, &key),
Err(e) => { Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://"); tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path)) local_ref(&path)
} }
}, },
None => Ok(local_ref(&path)), None => local_ref(&path),
} };
Ok((url, path))
} }
} }
/// 内容 sha256 全量 hex。落盘键与展示名回退共用同一 hash 口径(飞书文件入站无原始名
/// 时展示名取 `file_<hash 前 8>`,与落盘 hash 命名同源可对账)
pub fn content_hash(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(bytes))
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName /// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
/// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。 /// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话 /// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说 /// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String { fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _; let digest = content_hash(bytes)[..16].to_string();
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化 // 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty()); let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name let ext = name
@@ -280,6 +301,25 @@ mod tests {
tokio::fs::remove_dir_all(&dir).await.ok(); tokio::fs::remove_dir_all(&dir).await.ok();
} }
#[tokio::test]
async fn save_with_path_returns_readable_local_path() {
// 文件消息入站契约:引用 url 之外另给本机路径(模型 read_file 只认本机路径),
// 通用文件落盘字节无损可读
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let (url, path) = store
.save_with_path(b"doc-bytes", "application/octet-stream", Some("合同.pdf"))
.await
.unwrap();
assert!(url.starts_with("file://"));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}_合同.pdf", &content_hash(b"doc-bytes")[..16])
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"doc-bytes");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test] #[test]
fn content_key_stable_and_ext_aware() { fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None); let k1 = content_key(b"abc", "image/png", None);
+31 -3
View File
@@ -4,7 +4,7 @@
//! //!
//! L2 发送韧性:三级降级 //! L2 发送韧性:三级降级
//! L0 normal:后端 idle → 直接发送 //! L0 normal:后端 idle → 直接发送
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队) //! L1 queued: 后端 busy → 纯文本先试补充注入(并入当前轮,u-claw 语义);失败/带载荷 → 入队等待(≤30s 正常排队)
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出 //! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
//! //!
//! 耦合: //! 耦合:
@@ -356,7 +356,7 @@ export function drainQueue(convId?: string | null) {
* 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制): * 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制):
* *
* L0 normal: 后端 idle → 直接 doSend() * L0 normal: 后端 idle → 直接 doSend()
* L1 queued: 后端 busy → 入队等 AiCompleted 续发 * L1 queued: 后端 busy → 纯文本先试 ai_inject_runtime(补充并入当前轮);失败回退入队等 AiCompleted 续发
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm; * L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC * 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
* *
@@ -387,9 +387,37 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
backendGenerating = false backendGenerating = false
} }
// ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)── // ── L1:后端 busy → 纯文本先试补充注入,失败回退入队(消息已在本地显示,待后端空闲后自动发送)──
// 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。 // 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。
if (backendGenerating) { if (backendGenerating) {
// 补充注入旁路(u-claw 语义,与飞书渠道 feishu_channel 同源同一后端实现):纯文本补充
// 经 ai_inject_runtime 直写运行中会话,loop 下一轮自然消化——补充并进当前回答,不开新轮不入队。
// 注入通道只收文本:带图(parts 含 image)/带技能/带 mention 增强的消息注入会丢载荷
// (同飞书「图被丢、引导词空跑」判定),照旧入队走完整发送路径。
// Ok(false)=会话不存在、Err=非活跃/Stopping 均为常态路径:静默回退下方入队,
// 行为与接入注入前逐字一致。
const pureText = !skill && !(spans && spans.length > 0) && !(parts && parts.some(p => p.type === 'image'))
if (pureText && targetConvId) {
try {
const injected = await invoke<boolean>('ai_inject_runtime', { conversationId: targetConvId, message: text.trim() })
if (injected) {
// 本地气泡即时可见(后端已落库同内容,重进会话从 DB 加载不重复):
// 当前视图才 push,后台会话续发不污染当前视图(与下方 L0 既有口径一致)
if (targetConvId === state.activeConversationId) {
state.messages.push({
id: `user-${nextMsgId()}` as MessageId,
role: 'user',
content: text.trim(),
timestamp: Date.now(),
})
}
void emit('ai-toast', { msg: t('ai.injectMerged'), type: 'success' })
return
}
} catch {
// 非活跃(生成刚收尾的竞态窗口)是常态:静默回退入队,不弹错不打扰
}
}
if (state.queue.length >= QUEUE_LIMIT) { if (state.queue.length >= QUEUE_LIMIT) {
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT })) throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
} }
+2
View File
@@ -52,6 +52,8 @@ export default {
// UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent) // UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent)
queueCleared: 'Cleared {n} queued message(s)', queueCleared: 'Cleared {n} queued message(s)',
queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.', queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.',
// Success toast when a runtime supplement is merged into the in-flight answer (u-claw semantics: no new round)
injectMerged: 'Merged into the current answer; takes effect next round.',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}', approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent) // conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.', loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
+2
View File
@@ -52,6 +52,8 @@ export default {
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈) // UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
queueCleared: '已清 {n} 条排队消息', queueCleared: '已清 {n} 条排队消息',
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送', queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
// 运行中补充注入成功(u-claw 语义:纯文本补充并进当前回答,不开新轮)的 toast 提示
injectMerged: '已并入当前回答,下一轮生效',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}', approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) // 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接', loadConvFail: '对话列表加载失败,请检查网络或后端连接',