diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index a0c3a9e..90d73ee 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -310,6 +310,7 @@ mod tests { role: MessageRole::User, content: "字面量构造".into(), parts: None, + source: None, tool_call_id: None, tool_calls: None, model: None, @@ -368,6 +369,7 @@ mod tests { role: MessageRole::Assistant, content: "answer".to_string(), parts: None, + source: None, tool_call_id: None, tool_calls: None, model: None, @@ -429,6 +431,7 @@ mod tests { role: MessageRole::Assistant, content: "answer".to_string(), parts: None, + source: None, tool_call_id: None, tool_calls: None, model: None, diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 8affd7a..de8170b 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -527,6 +527,31 @@ pub(crate) async fn run_agentic_loop( // 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。 conv.loop_max_rounds = max_iterations; 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.notify.clone(), @@ -2013,6 +2038,9 @@ async fn prepare_round_inputs( // 条目已消失则跳过,防 conv() 惰性创建孤儿状态 if session.conv_read(conv_id).is_some() { 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) { Some(c) => c, @@ -2218,7 +2246,10 @@ async fn finalize_round_output( }) .collect(); } else { - update_pinned_goals(&mut session, conv_id, tool_calls_acc); + // 工具推理流水账停写(2026-10-15 目标语义重构):「执行 xxx/查看 xxx」是手段记录 + // 不是用户目标,入 pinned_goals 挤占真目标且压缩/重启后即失忆;工具轨迹已由 + // 轨迹卡与审计表完整覆盖。目标来源收敛为:首条任务消息(入口写入)+UPDATE_GOALS + // 显式修正(上方分支,LLM 显式目标管理继续有效)。 } } } @@ -2345,19 +2376,49 @@ async fn finalize_round_output( // 正常收敛(loop 主体置 converged 后 break) // P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息 // (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。 - if round_truncated_by_max_tokens { + // 注入未消化守卫(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 notice = ChatMessage::system( - "⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。", - ); - notice.model = Some(resolved_model.to_string()); - conv.messages.push(notice); + let mut tip = ChatMessage::system(format!( + "用户在回答生成期间补充了 {pending_injects} 条消息(见对话记录中的补充指令);\ + 请结合补充内容继续或修正上面的回答,勿忽略。" + )); + tip.model = Some(resolved_model.to_string()); + conv.messages.push(tip); } - tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示"); + tracing::info!( + conv_id = %conv_id, pending = pending_injects, + "[ai] 收敛时检出未消化注入,插提示续一轮消化" + ); + // 不 return Converged:掉到函数尾部 RunTools(本轮无工具空转), + // loop 顶部正常续轮,补充指令获得完整消费轮。 + } else { + if round_truncated_by_max_tokens { + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + let mut notice = ChatMessage::system( + "⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。", + ); + notice.model = Some(resolved_model.to_string()); + conv.messages.push(notice); + } + tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示"); + } + return RoundEpilogue::Converged; } - return RoundEpilogue::Converged; } } @@ -3602,6 +3663,7 @@ fn push_assistant_message( // ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ── // 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。 // 入参 session 需外层调用方持锁;纯内存 mutate,无 await。 +#[allow(dead_code)] fn update_pinned_goals( session: &mut AiSession, conv_id: &str, diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index f5b4a8a..9035d4c 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -1197,6 +1197,9 @@ pub(crate) async fn inject_runtime_message( if extended { conv.round_budget_extra += 1; } + // 未消化计数(2026-10-14 实测):纯问答轮一轮收敛无下一轮,末轮扩轮机制(loop_max>0 才生效) + // 兜不住;收敛出口凭此计数感知「组装后有注入到达」并续一轮消化。 + conv.pending_injects = conv.pending_injects.saturating_add(1); drop(session); // 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉 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) } +/// 桌面端补充注入入口(桌面 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 { + inject_runtime_message(&state, &conversation_id, &message).await +} + /// 批准/拒绝挂起的工具调用 #[tauri::command] pub async fn ai_approve( diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 7aaeae6..37fb1af 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -1065,6 +1065,10 @@ pub struct PerConvState { /// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。 /// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。 pub round_budget_extra: usize, + /// 未消化注入计数(2026-10-14 实测):inject_runtime_message 每次 +1;每轮 build_for_request + /// 组装后清零(消息已进本轮请求=消化);收敛出口查 >0 则不收敛续一轮,治「纯问答轮 + /// 一轮收敛,生成期间到达的补充指令无人消费只能等下次请求」的丢失。内存态不落库。 + pub pending_injects: u32, /// 当前 agent 循环的语言设置(用于审批后恢复循环) pub agent_language: Option, /// 用户指定模型 override(主对话专用,语义同 AiSession.model_override) @@ -1266,6 +1270,7 @@ impl PerConvState { iteration_used: 0, loop_max_rounds: 0, round_budget_extra: 0, + pending_injects: 0, agent_language: None, model_override: None, session_trust: HashSet::new(), diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index 291bc16..d85acac 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -384,6 +384,46 @@ const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查 /// 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(模型使用按需转换)。 @@ -396,53 +436,20 @@ async fn download_feishu_image( message_id: &str, file_key: &str, ) -> Result { - // 下载带防线(对齐 u-claw fetchResource 经验):3 次指数退避(1s/4s)+单次 120s 超时 - // ——飞书资源接口偶发抖动,无重试时用户收到一次性失败;超时防接口挂起拖死受理链。 - // 空响应与错误同待遇进重试(可能为服务端瞬时异常),耗尽后报最后一次原因 - 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 { + 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),请压缩后重发", - 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())?; // 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析), // 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。 // 飞书 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 { url: Some(url), 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 { + (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, @@ -588,12 +656,21 @@ async fn handle_message( ) { // D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳 ctx.last_alive.store(now_secs(), Ordering::SeqCst); - // 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart); - // 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入 - // msg.resources(ChannelResource),无需自行解析 content JSON。 - let image_key = if msg.message_type == "image" { + // 群聊响应门控对齐 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()), + Some(r) => (Some(r.file_key.clone()), None), None => { let _ = reply_markdown_card( &ctx.client, @@ -604,26 +681,39 @@ async fn handle_message( 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 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。 - // 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述 + // #25 非文本消息静默丢修复:回提示卡,不再零反馈。文本/图片/文件已支持, + // 余下语音等类型如实告知规划中 let _ = reply_markdown_card( &ctx.client, &msg.message_id, - "📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。", + "📎 暂支持文本、图片与文件消息,语音等其他类型规划中。", ) .await; return; } else { - None + (None, None) }; - if msg.chat_type == "group" && !msg.mentioned_bot { - return; - } - // 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境; - // msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂 - let text = if image_key.is_some() { + // 附件消息不带文字(飞书 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 承载,占位串只污染文本 @@ -685,6 +775,30 @@ async fn handle_message( 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 活跃时这两类文本否则会被当补充 // 指令注入任务;未知命令词落回常规任务路径,零行为变更 @@ -836,9 +950,10 @@ async fn handle_message( 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 只收文本,注入则图被丢、模型只见 - // 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发) - if image_key.is_none() && state.conv_states.is_active(&cid) { + // 附件消息(图片/文件)不注入,走与图片相同的后续链: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; @@ -2402,9 +2517,10 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap 判定),超限给可读错误话术(含实际大小,引导压缩/拆分) + 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00c0c47..6b9c74b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -496,6 +496,8 @@ pub fn run() { commands::ai::ai_pending_tool_calls, commands::ai::ai_chat_clear, commands::ai::ai_is_generating, + // 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队) + commands::ai::ai_inject_runtime, // F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩 commands::ai::ai_chat_clear_context, commands::ai::ai_chat_compress_context, diff --git a/src-tauri/src/media_store.rs b/src-tauri/src/media_store.rs index 9e18172..9d705af 100644 --- a/src-tauri/src/media_store.rs +++ b/src-tauri/src/media_store.rs @@ -98,6 +98,20 @@ impl MediaStore { media_type: &str, original_name: Option<&str>, ) -> Result { + 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 dir = self.data_dir.join("media"); tokio::fs::create_dir_all(&dir) @@ -112,26 +126,33 @@ impl MediaStore { return Err(format!("媒体写入失败: {e}")); } } - match &self.oss { + let url = match &self.oss { 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) => { 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 命名同源可对账) +pub fn content_hash(bytes: &[u8]) -> String { + use sha2::Digest as _; + hex::encode(sha2::Sha256::digest(bytes)) +} + /// 内容寻址文件名:`[_<清洗原始名>]`(对齐 u-claw safeAttachName /// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。 /// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话 /// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说 fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String { - use sha2::Digest as _; - let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]); + let digest = content_hash(bytes)[..16].to_string(); // 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化 let name = original_name.map(sanitize_name).filter(|s| !s.is_empty()); let ext = name @@ -280,6 +301,25 @@ mod tests { 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] fn content_key_stable_and_ext_aware() { let k1 = content_key(b"abc", "image/png", None); diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 2a18bff..c7d02bb 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -4,7 +4,7 @@ //! //! L2 发送韧性:三级降级 //! L0 normal:后端 idle → 直接发送 -//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队) +//! L1 queued: 后端 busy → 纯文本先试补充注入(并入当前轮,u-claw 语义);失败/带载荷 → 入队等待(≤30s 正常排队) //! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出 //! //! 耦合: @@ -356,7 +356,7 @@ export function drainQueue(convId?: string | null) { * 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制): * * L0 normal: 后端 idle → 直接 doSend() - * L1 queued: 后端 busy → 入队等 AiCompleted 续发 + * L1 queued: 后端 busy → 纯文本先试 ai_inject_runtime(补充并入当前轮);失败回退入队等 AiCompleted 续发 * L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm; * 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC * @@ -387,9 +387,37 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part backendGenerating = false } - // ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)── + // ── L1:后端 busy → 纯文本先试补充注入,失败回退入队(消息已在本地显示,待后端空闲后自动发送)── // 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。 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('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) { throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT })) } diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts index c47db70..af2d166 100644 --- a/src/i18n/en/ai.ts +++ b/src/i18n/en/ai.ts @@ -52,6 +52,8 @@ export default { // UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent) queueCleared: 'Cleared {n} queued message(s)', 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}', // 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.', diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts index ccd4566..3eb9c83 100644 --- a/src/i18n/zh-CN/ai.ts +++ b/src/i18n/zh-CN/ai.ts @@ -52,6 +52,8 @@ export default { // UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈) queueCleared: '已清 {n} 条排队消息', queueWillContinue: '已停止,{n} 条排队消息仍将自动发送', + // 运行中补充注入成功(u-claw 语义:纯文本补充并进当前回答,不开新轮)的 toast 提示 + injectMerged: '已并入当前回答,下一轮生效', approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}', // 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) loadConvFail: '对话列表加载失败,请检查网络或后端连接',