diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index 0da7328..8a95dde 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -410,10 +410,11 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String { } else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX { // 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。 // 按字符数截断保留头尾,保证压缩至少生效。 + // 标记格式与纯文本单行分支对齐:含 `(截断)` 连续子串,便于上层断言/解析。 let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect(); let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect(); *field = serde_json::Value::String(format!( - "{}...(截断,原始 {} 字符)...{}", + "{}...(截断) 原始 {} 字符...{}", head, s.chars().count(), tail )); truncated = true; diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 1d0cd9e..90385c8 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -196,12 +196,21 @@ pub const STALL_BREAKER_GOAL_REMIND: bool = true; /// /// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试, /// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls -/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 → +/// 后取末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一为 key, +/// 入滚动窗口(CIRCUIT_BREAKER_WINDOW)统计,同一 key 在窗口内累计达此阈值 → /// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。 /// /// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。 pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3; +/// L1 断路器滑动窗口大小:仅保留最近 N 条失败记录参与计数。 +/// +/// 治"长任务偶发失败误熔断":全 loop 累加会让早期偶发失败与后期同类失败叠加触发。 +/// 滚动窗口让计数只反映最近的失败密度——长任务中途偶发 1~2 次同类失败不触发, +/// 真正的连续死循环(N 条全同 key)才触发。N=20:对齐 max_iterations 量级,既覆盖 +/// 单轮并行失败爆发,又足够长以容忍偶发抖动。 +pub const CIRCUIT_BREAKER_WINDOW: usize = 20; + /// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations /// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。 pub const CIRCUIT_BREAKER_ENABLED: bool = true; @@ -828,11 +837,13 @@ pub(crate) async fn run_agentic_loop( // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 let mut converged = false; - // L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。 - // loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 - let mut fail_counts: std::collections::HashMap = std::collections::HashMap::new(); + // L1 断路器:连续同类工具失败滚动窗口(VecDeque,长度封顶 CIRCUIT_BREAKER_WINDOW)。 + // key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key, + // 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 + // 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。 + let mut fail_window: std::collections::VecDeque = std::collections::VecDeque::new(); - // G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。 + // G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_window 同生命周期)。 // 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1, // 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。 let mut stall_count: u32 = 0; @@ -1548,9 +1559,9 @@ pub(crate) async fn run_agentic_loop( ); // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 - // count_recent_failures 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 计数。 + // count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。 if CIRCUIT_BREAKER_ENABLED { - let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_counts).await; + let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_window).await; // 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。 if max_count >= CIRCUIT_BREAKER_THRESHOLD { tracing::warn!( @@ -1694,12 +1705,21 @@ async fn heartbeat_loop( } // ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ── -// 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 累入 fail_counts。 -// 返回 (max_count, sample_key):max_count=0 表示无失败,不触发熔断。 +// 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一 key, +// 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)。 +// 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。 +// +// 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减): +// 1. key 归一:失败 envelope 内容(不同路径/参数)前 40 字符易把同类失败拆成多 key +// (治不住)或异类失败误聚成一类(误熔断)。改读结构化信号—— +// is_failure_content 已结构化判成败,本函数按 tool_name + 错误类别(exit_code/错误关键词 +// 哈希)归一 key,同一工具的同类错误稳定聚到同一 key。 +// 2. 滚动窗口:全 loop 累加会让早期偶发失败与后期叠加触发误熔断。VecDeque 仅保留最近 N 条, +// 计数只反映最近失败密度——长任务中途偶发 1~2 次不触发,真正连续死循环才触发。 async fn count_recent_failures( session_arc: &Arc>, conv_id: &str, - fail_counts: &mut std::collections::HashMap, + fail_window: &mut std::collections::VecDeque, ) -> (u32, String) { let messages = { let session = session_arc.lock().await; @@ -1708,23 +1728,95 @@ async fn count_recent_failures( None => Vec::new(), } }; + // 末尾连续 Tool 消息(本轮工具回填结果)。 let recent_tool_results: Vec<&ChatMessage> = messages .iter().rev() .take_while(|m| matches!(m.role, MessageRole::Tool)) .collect(); - for m in recent_tool_results { - if is_failure_content(&m.content) { - let key: String = m.content.chars().take(40).collect(); - *fail_counts.entry(key).or_insert(0) += 1; + if recent_tool_results.is_empty() { + return (0u32, String::new()); + } + // tool_call_id → tool_name 反查表:遍历 Assistant 消息的 tool_calls(开销与消息数线性)。 + // tool_result 消息只带 tool_call_id,工具名在其前驱 Assistant 头里。 + let mut id_to_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for m in &messages { + if let MessageRole::Assistant = m.role { + if let Some(calls) = m.tool_calls.as_ref() { + for c in calls { + id_to_name.insert(c.id.as_str(), c.function.name.as_str()); + } + } } } - fail_counts - .iter() - .max_by_key(|(_, &v)| v) - .map(|(k, &v)| (v, k.clone())) + // 本轮失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。 + for m in recent_tool_results.into_iter().rev() { + if is_failure_content(&m.content) { + let tool_name = m + .tool_call_id + .as_deref() + .and_then(|id| id_to_name.get(id).copied()) + .unwrap_or("unknown_tool"); + let key = failure_key(tool_name, &m.content); + fail_window.push_back(key); + while fail_window.len() > CIRCUIT_BREAKER_WINDOW { + fail_window.pop_front(); + } + } + } + // 窗口内同 key 频次最高者 = 熔断候选。 + let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + for k in fail_window.iter() { + *freq.entry(k.as_str()).or_insert(0) += 1; + } + freq.into_iter() + .max_by_key(|(_, v)| *v) + .map(|(k, v)| (v, k.to_string())) .unwrap_or((0u32, String::new())) } +/// 归一失败 key:tool_name + 错误类别。保证同一工具的同类错误稳定映射到同一 key。 +/// +/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。 +/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`: +/// 用 fxhash 风格简单 FNV-1a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key +/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。 +/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数, +/// 此处兜底返回 `tool_name::unknown` 防御。 +fn failure_key(tool_name: &str, content: &str) -> String { + let Ok(v) = serde_json::from_str::(content) else { + return format!("{}::unknown", tool_name); + }; + if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { + return format!("{}::exit={}", tool_name, exit); + } + // 取 error 字段文本做稳定哈希;无 error 字段则按 status 兜底。 + let bucket = match v.get("status").and_then(|s| s.as_str()) { + Some("failed") => "failed", + Some("error") => "error", + _ => "other", + }; + let err_text = v + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("") + .trim(); + if err_text.is_empty() { + return format!("{}::{}", tool_name, bucket); + } + let h = fnv1a_32(err_text.as_bytes()); + format!("{}::{}:{:08x}", tool_name, bucket, h) +} + +/// FNV-1a 32-bit 哈希(零依赖,纯函数,断路器 key 归一专用;非密码学用途)。 +fn fnv1a_32(bytes: &[u8]) -> u32 { + let mut hash: u32 = 0x811c9dc5; + for &b in bytes { + hash ^= b as u32; + hash = hash.wrapping_mul(0x0100_0193); + } + hash +} + // ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ── // 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含 // error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容 diff --git a/src-tauri/src/commands/ai/agentic/workflow_context.rs b/src-tauri/src/commands/ai/agentic/workflow_context.rs index 2d51d3c..5278bdd 100644 --- a/src-tauri/src/commands/ai/agentic/workflow_context.rs +++ b/src-tauri/src/commands/ai/agentic/workflow_context.rs @@ -6,6 +6,16 @@ //! //! 调用方(前端/workflow 引擎)在启动工作流时将摘要存入 //! `PerConvState.workflow_dag_summary`,agentic loop 自动注入 system prompt。 +//! +//! 预留说明(dead_code):本模块的 3 个函数当前无生产调用方—— +//! T4 消费侧已落地(`agentic/mod.rs` 在构建 system prompt 时读 +//! `PerConvState.workflow_dag_summary` 并注入),但生产侧尚无人写入该字段 +//! (`PerConvState::new` 初始化为 `None`,无代码调用 `build_dag_summary`)。 +//! 即完整链路为「工作流启动器 → 调本模块产出摘要 → 写 workflow_dag_summary +//! → agentic loop 注入 system prompt」,中间「生产者」环节待接通。 +//! 属为后续工作流上下文注入准备的预留,非真死代码,故 allow 抑制告警。 + +#![allow(dead_code)] use df_workflow::dag_def::DagDef; diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index 3571a5b..0c709a7 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -62,9 +62,10 @@ use crate::state::AppState; // 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取), // 故 import 不含 Manager(避免 unused)。 use crate::commands::ai::{ - ai_approve, ai_authorize_dir, ai_chat_send, ai_chat_stop, ai_conversation_rename, - ai_continue_loop, ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, - AiChatEvent, ApprovalKind, ConvSummary, PendingApproval, + ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit, + ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop, + ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind, + ConvSummary, PendingApproval, }; // F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command], // 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。 @@ -74,7 +75,8 @@ use crate::commands::ai::skills::SkillInfo; // MentionSpanDto 来自 df_types::augmentation(df-types 已是 src-tauri 依赖,chat.rs:20 同源)。 use df_types::augmentation::MentionSpanDto; // F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。 -use df_ai::provider::ChatMessage; +// ContentPart:ai_chat_force_send 的 parts 参数类型(多模态,远程透传同 ai_chat_send)。 +use df_ai::provider::{ChatMessage, ContentPart}; // ============================================================ // MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93) @@ -164,6 +166,33 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State< route_send_message(&app, &state, command.args).await; } + // ── force_send:ai_chat_force_send(强制复位+发送,绕过 generating guard) ── + // 与 send_message 同语义但「强制」:内部原子复位目标 conv 旧生成态再占用。 + // 故 R1 generating 检查在此禁用(否则与「强制」语义矛盾,用户无法挣脱卡死态)。 + // 对齐桌面端 force_send IPC,补 miniapp 经 relay 调不到的路由缺口。 + "force_send" => { + route_force_send(&app, &state, command.args).await; + } + + // ── edit_message:ai_chat_edit(改末条 user 后重发) ── + // edit 内部自有 can_accept_request guard(生成中返 Err),桥接层不再加 R1 + // (R1 仅 send_message 硬性双保险,edit/compress/clear 各自有内部 guard)。 + "edit_message" => { + route_edit_message(&app, &state, command.args).await; + } + + // ── compress_context:ai_chat_compress_context(LLM 压缩历史) ── + // 内部 is_compressing 防重入 guard,失败返 Err。对齐桌面端 IPC 路由缺口。 + "compress_context" => { + route_compress_context(&app, &state, command.args).await; + } + + // ── clear_context:ai_chat_clear_context(分段清历史) ── + // 内部按 PROTECT_COUNT 保留近条,空会话 noop + emit。对齐桌面端 IPC 路由缺口。 + "clear_context" => { + route_clear_context(&app, &state, command.args).await; + } + // ── stop:ai_chat_stop(conversation_id?) ── // 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431), // 与其它命令的 (app, state, ...) 顺序不同,此处对齐其真实签名。 @@ -741,6 +770,197 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: } } +// ============================================================ +// 桌面独占命令跨端补路由(force_send / edit_message / compress_context / clear_context) +// ============================================================ +// +// 背景:此 4 命令桌面端 IPC 有(chat.rs ai_chat_force_send / ai_chat_edit / +// ai_chat_compress_context / ai_chat_clear_context),但 remote_bridge 路由表缺, +// miniapp 经 relay 调不到。本批仅补 4 路由,对齐 route_send_message 模式: +// - 直接 async 调对应 ai_chat_xxx(非 IPC invoke) +// - 错误处理对齐:Err 转 AiError emit 回 miniapp(命令返 Err 静默则 miniapp 不知失败) +// +// R1 generating 检查的取舍: +// - force_send:**禁用 R1**(强制复位+发送是其核心语义,R1 拦截会剥夺「挣脱卡死态」能力) +// - edit / compress / clear:不加 R1(各自内部已有 guard —— can_accept_request / +// is_compressing / 空会话 noop;R1 仅 send_message 硬性双保险,不泛化) + +/// `force_send` 路由 —— 强制复位目标 conv 旧生成态后发送(绕过 generating guard)。 +/// +/// 与 `route_send_message` 同参数面(message / conversation_id? / skill? / model_override? +/// / parts? / mention_spans?),区别: +/// - **禁用 R1 generating 检查** —— force_send 语义就是「无视当前生成态强制重发」, +/// ai_chat_force_send 内部原子「复位目标 conv 的 generating + 占用」(锁内瞬变无观察窗), +/// R1 拦截会让 force_send 永远打不出去(用户卡死时无法挣脱)。 +/// - 仍广播 AiUserMessage —— force_send 会追加新 user 消息(同 send_message), +/// 桌面 useAiEvents 需据此补 user 气泡(否则只见孤立 assistant 响应)。 +/// +/// message 必填,缺失跳过。Err 转 AiError emit 回 miniapp(对齐 route_send_message)。 +async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Value) { + let message = match args_get_string(&args, "message") { + Some(m) => m, + None => { + tracing::warn!("[remote_bridge] force_send 缺 message 参数,忽略"); + return; + } + }; + let conversation_id = args_get_string(&args, "conversation_id"); + let model_override = args_get_string(&args, "model_override"); + let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty()); + let mention_spans = args_get_mention_spans(&args, "mention_spans"); + // parts 多模态片段(对齐 ai_chat_force_send 第 6 参,远程透传同 ai_chat_send)。 + let parts = args_get_parts(&args, "parts"); + + // 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息, + // 桌面端需据此补 user 气泡,防孤立 assistant 响应)。 + let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage { + message: message.clone(), + conversation_id: conversation_id.clone(), + }); + let result = ai_chat_force_send( + app.clone(), + state.clone(), + message, + None, // language:远程默认不传,内部 fallback "zh-CN" + skill, // skill:/ 联想选中技能名透传(技能正文注入) + model_override, // model_override:远程透传用户选择 + parts, // parts:多模态片段透传(对齐 ai_chat_force_send) + mention_spans, // mention_spans:@ mention 区间透传 + conversation_id.clone(),// conversation_id:强制复位+发送仅作用于目标 conv + ) + .await; + if let Err(err_msg) = result { + tracing::warn!( + error = %err_msg, + conv_id = ?conversation_id, + "[remote_bridge] ai_chat_force_send 返回 Err,转 AiError emit 回 miniapp" + ); + let _ = app.emit( + "ai-chat-event", + AiChatEvent::AiError { + error: err_msg, + error_type: Some(crate::commands::ai::ErrorType::Unknown), + conversation_id, + }, + ); + } +} + +/// `edit_message` 路由 —— 改末条 active user 消息 content 后重发(truncate 其后)。 +/// +/// 参数:conversation_id(必填) + new_message(必填) + language? + model_override?。 +/// 内部自有 can_accept_request guard(生成中返 Err),不加 R1。 +/// +/// 不广播 AiUserMessage —— edit 是替换末条 user(非新增),前端经 AiChatEvent 流自维护 +/// (广播会致双气泡)。conversation_id / new_message 必填,缺失跳过。 +async fn route_edit_message(app: &AppHandle, state: &State<'_, AppState>, args: Value) { + let (conversation_id, new_message) = match ( + args_get_string(&args, "conversation_id"), + args_get_string(&args, "new_message"), + ) { + (Some(id), Some(msg)) => (id, msg), + _ => { + tracing::warn!( + "[remote_bridge] edit_message 缺 conversation_id 或 new_message 参数,忽略" + ); + return; + } + }; + let language = args_get_string(&args, "language"); + let model_override = args_get_string(&args, "model_override"); + + let result = ai_chat_edit( + app.clone(), + state.clone(), + conversation_id.clone(), + new_message, + language, + model_override, + ) + .await; + if let Err(err_msg) = result { + tracing::warn!( + error = %err_msg, + conv_id = %conversation_id, + "[remote_bridge] ai_chat_edit 返回 Err,转 AiError emit 回 miniapp" + ); + let _ = app.emit( + "ai-chat-event", + AiChatEvent::AiError { + error: err_msg, + error_type: Some(crate::commands::ai::ErrorType::Unknown), + conversation_id: Some(conversation_id), + }, + ); + } +} + +/// `compress_context` 路由 —— LLM 压缩目标 conv 历史(保留近 PROTECT_COUNT 条)。 +/// +/// 参数:conversation_id(必填) + language?。内部 is_compressing 防重入 guard(返 Err), +/// LLM 失败则消息状态完全不变。不加 R1(compress 期间 loop 仍活跃但属预期)。 +/// conversation_id 必填,缺失跳过。Err 转 AiError emit 回 miniapp。 +async fn route_compress_context(app: &AppHandle, state: &State<'_, AppState>, args: Value) { + let conversation_id = match args_get_string(&args, "conversation_id") { + Some(id) => id, + None => { + tracing::warn!("[remote_bridge] compress_context 缺 conversation_id 参数,忽略"); + return; + } + }; + let language = args_get_string(&args, "language"); + + let result = + ai_chat_compress_context(app.clone(), state.clone(), conversation_id.clone(), language).await; + if let Err(err_msg) = result { + tracing::warn!( + error = %err_msg, + conv_id = %conversation_id, + "[remote_bridge] ai_chat_compress_context 返回 Err,转 AiError emit 回 miniapp" + ); + let _ = app.emit( + "ai-chat-event", + AiChatEvent::AiError { + error: err_msg, + error_type: Some(crate::commands::ai::ErrorType::Unknown), + conversation_id: Some(conversation_id), + }, + ); + } +} + +/// `clear_context` 路由 —— 分段清目标 conv 历史(保留近 PROTECT_COUNT 条)。 +/// +/// 参数:conversation_id(必填)。空会话/全在保护区 noop + emit AiContextCleared。 +/// 不加 R1(clear 是即时段落操作,无生成态依赖)。conversation_id 必填,缺失跳过。 +/// Err 转 AiError emit 回 miniapp。 +async fn route_clear_context(app: &AppHandle, state: &State<'_, AppState>, args: Value) { + let conversation_id = match args_get_string(&args, "conversation_id") { + Some(id) => id, + None => { + tracing::warn!("[remote_bridge] clear_context 缺 conversation_id 参数,忽略"); + return; + } + }; + + let result = ai_chat_clear_context(app.clone(), state.clone(), conversation_id.clone()).await; + if let Err(err_msg) = result { + tracing::warn!( + error = %err_msg, + conv_id = %conversation_id, + "[remote_bridge] ai_chat_clear_context 返回 Err,转 AiError emit 回 miniapp" + ); + let _ = app.emit( + "ai-chat-event", + AiChatEvent::AiError { + error: err_msg, + error_type: Some(crate::commands::ai::ErrorType::Unknown), + conversation_id: Some(conversation_id), + }, + ); + } +} + /// R1 检查:目标 conv 是否正在生成。返 `Some(conv_id)` 表示拒绝(该 conv 在跑), /// 返 `None` 表示放行(可发)。 /// @@ -823,6 +1043,37 @@ fn args_get_mention_spans(args: &Value, key: &str) -> Option } } +/// 从 args 取 parts 字段(数组)反序列化为 `Vec`(多模态片段)。 +/// +/// 用于 route_force_send 透传 miniapp 多模态片段到 ai_chat_force_send。对齐 +/// ai_chat_force_send 第 6 参 `Option>`(chat.rs:1539)。 +/// +/// 缺失 / 非数组 / 元素反序列化失败 → None(对齐 miniapp MVP 无 parts 走 None 的旧路径, +/// 不因 parts 脏数据阻断 force_send 路由)。失败仅 log warn,不 panic。语义同 args_get_mention_spans。 +fn args_get_parts(args: &Value, key: &str) -> Option> { + match args.get(key) { + Some(Value::Array(_)) => match serde_json::from_value::>(args[key].clone()) + { + Ok(parts) => { + if parts.is_empty() { + None + } else { + Some(parts) + } + } + Err(e) => { + tracing::warn!( + error = %e, + "[remote_bridge] parts 反序列化失败,降级 None(不阻断 force_send)" + ); + None + } + }, + // 缺失或非数组 → None(向后兼容无多模态的远程调用)。 + _ => None, + } +} + // ============================================================ // 单元测试 // ============================================================