//! F-260622-01 跨端 AI Chat Phase3 阶段3 桥接层 — MiniCommand→Tauri command 路由 //! //! 设计文档:docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md //! (§2.2 Command 下行路由表 + §3.3 R1 同 conv 并发发送兜底 + 附录 B 命令清单) //! //! ## 定位 //! //! 方案 A(tunnel 纯透传 + device 端桥接解协议)下的 device 端桥接模块。 //! tunnel 入站回调改为上抛原始 `serde_json::Value`(不再强类型解 TunnelCommand), //! 本模块作为该回调的处理器,把 miniapp 发来的 `MiniCommand{cmd, args}` 翻译成 //! 对应 src-tauri Tauri command 的直接 async 调用(非 IPC invoke)。 //! //! ## 路由表(§2.2) //! //! | MiniCommand.cmd | 调用的 Tauri command | 关键参数(args 字段) | //! |-----------------------|----------------------|----------------------------------------| //! | `send_message` | `ai_chat_send` | message, conversation_id?, model_override? | //! | `stop` | `ai_chat_stop` | conversation_id? | //! | `regenerate` | `ai_regenerate` | conversation_id, language?, model_override? | //! | `approve` | `ai_approve` | tool_call_id, approved | //! | `authorize_dir` | `ai_authorize_dir` | tool_call_id, decision | //! | `continue_loop` | `ai_continue_loop` | conversation_id | //! | `stop_loop` | `ai_stop_loop` | conversation_id | //! | `switch_conversation` | (无,MVP 不处理) | — | //! | `list_conversations` | (无,直接读库+事件推送) | — | //! | `load_messages` | (无,直接读库+事件推送) | conversation_id | //! | `rename_conversation` | `ai_conversation_rename` | conversation_id, title | //! | `sync_pending` | (无,读 session 重发审批事件) | conversation_id | //! | (未知 cmd) | (兜底臂:log+忽略) | — | //! //! ## R1 兜底(硬性,不可省,§3.3 + D6) //! //! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 双轨口径: //! CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool)。 //! generating=true 则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」, //! 不调 ai_chat_send。这是 miniapp 与桌面同时给同 conv 发消息的双保险, //! 无论 ai_chat_send 内部是否已有 generating guard 都加(防御性编程)。 //! //! ## 集成 //! //! 本模块仅暴露 `handle_remote_command` 入口,setup 集成(注册为 tunnel on_command //! 回调)留待联调轮做,不在本批范围。 //! //! dead_code:本模块入口 `handle_remote_command` 及其内部链(route_send_message / //! check_generating_reject / MiniCommand::from_payload / args_get_*)已接 tunnel //! on_command 回调(lib.rs:150 setup 注册),非死代码。保留 `#![allow(dead_code)]` //! 仅为防 route_* 等部分内部函数未被全用时的告警(预留保留)。 #![allow(dead_code)] use serde::Deserialize; use serde_json::Value; use tauri::{AppHandle, Emitter, State}; use crate::state::AppState; // super::super = commands::ai(commands/chat.rs 的 commands 模块父级是 commands::ai) // Tauri command 函数经 commands/mod.rs 的 `pub use self::chat::*;` + ai/mod.rs 的 // `pub use self::commands::*;` 透传到 crate::commands::ai 路径,这里直接复用该路径。 // // 注:集成轮 setup 注册 tunnel on_command 回调时,会用 `app.state::()`(Manager trait) // 构造 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, }; // F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command], // 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。 use crate::commands::{idea::list_ideas, project::list_projects, task::list_tasks}; // F-#95 跨端技能/mention:SkillInfo(commands/ai/skills.rs) + MentionSpanDto(df-types augmentation)。 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; // CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。 use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED; // ============================================================ // MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93) // ============================================================ /// 小程序 → 桌面端 Command 协议载体。 /// /// 对齐 `apps/df-miniapp/src/types/relay.ts:88-93` 的 `MiniCommand` interface: /// ```ts /// interface MiniCommand { cmd: string; args: Record } /// ``` /// `cmd` 是 Tauri command 名(relay.ts:82 注释「对齐 Tauri command 名」), /// `args` 是原始参数对象(对齐各 command 参数签名)。 /// /// 反序列化容忍 `args` 缺省(relay.ts:90 标注 args 为必填,但防御性编程允许缺失 → 默认空 Object)。 #[derive(Debug, Clone, Deserialize)] pub struct MiniCommand { /// Tauri command 名(如 "send_message" / "stop" / "approve" ...) pub cmd: String, /// 命令参数(原始 JSON 对象,缺失时回退空 Object 便于各臂 from_value 兜底) /// BUG-260623-05:`#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object #[serde(default = "default_empty_object")] pub args: Value, } /// MiniCommand.args 缺省值:空 Object。 /// `#[serde(default)]` 对 serde_json::Value 给 Null(非 Object),不符各臂 from_value 兜底语义。 fn default_empty_object() -> Value { serde_json::json!({}) } impl MiniCommand { /// 从原始 payload 反序列化为 MiniCommand。 /// /// 失败场景:payload 非 Object / 缺 `cmd` 字段 / `cmd` 非 string。 /// 调用方(handle_remote_command)失败时 log+忽略,不崩溃。 fn from_payload(payload: &Value) -> Result { serde_json::from_value(payload.clone()) } } // ============================================================ // 主入口:handle_remote_command(供 tunnel on_command 回调调用) // ============================================================ /// 远程命令处理入口 —— miniapp 发来的 MiniCommand payload 路由到 Tauri command。 /// /// 供 tunnel `on_command` 回调集成时调用(setup 集成留联调轮)。 /// /// 流程: /// 1. 反序列化 payload 为 [`MiniCommand`](失败 → log+忽略,非崩溃路径) /// 2. `match cmd` 路由到对应 Tauri command 直接 async 调用 /// 3. send_message 路由前置 R1 generating 检查(硬性兜底) /// 4. switch_conversation 无对应 command → log+忽略(§2.2 决策 a MVP 不处理) /// 5. 未知 cmd → log+忽略(兜底臂) /// /// Tauri command 返回值忽略 —— 结果经 AiChatEvent 回流(事件透传阶段2 已建通路), /// 不走 command 返回值(对齐 §2.2 设计:command 返回值忽略,结果经事件回流)。 /// /// `state` 参数签名用 `State<'_, AppState>`(与 Tauri command 第二参同类型), /// 集成时由 `app.state::()` 构造(Manager trait 提供,见 agentic/mod.rs:526 用法)。 pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<'_, AppState>) { // 反序列化失败:payload 非 {cmd, args} 结构(可能 miniapp 发了未知格式 / relay 误投递)。 // log+忽略,不崩溃 —— 对齐 §1.2 兜底语义「非法命令到运行时桥接层才报错,match 末尾兜底臂 log+忽略」。 let command = match MiniCommand::from_payload(&payload) { Ok(c) => c, Err(e) => { tracing::warn!( error = %e, "[remote_bridge] payload 反序列化 MiniCommand 失败,忽略(非 {{cmd, args}} 结构?)" ); return; } }; tracing::info!( cmd = %command.cmd, "[remote_bridge] 收到远程命令,开始路由" ); // match 路由表(§2.2)。各臂提取 args 字段,调对应 Tauri command。 // 字段缺失用稳健默认(Option/空字符串),不中断路由(命令内部自有参数校验,缺失会返 Err, // 调用方 ignore 返回值 —— 结果经事件回流)。 match command.cmd.as_str() { // ── send_message:R1 前置 generating 检查 → ai_chat_send ── "send_message" => { route_send_message(&app, &state, command.args).await; } // ── stop:ai_chat_stop(conversation_id?) ── // 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431), // 与其它命令的 (app, state, ...) 顺序不同,此处对齐其真实签名。 "stop" => { let conversation_id = args_get_string(&command.args, "conversation_id"); let _ = ai_chat_stop(state, app, conversation_id).await; } // ── regenerate:ai_regenerate(conversation_id, language?, model_override?) ── "regenerate" => { // conversation_id 必填,缺失跳过(ai_regenerate 内部无 conv 会失败,日志告警) match args_get_string(&command.args, "conversation_id") { Some(conversation_id) => { let language = args_get_string(&command.args, "language"); let model_override = args_get_string(&command.args, "model_override"); let _ = ai_regenerate(app, state, conversation_id, language, model_override).await; } None => { tracing::warn!( "[remote_bridge] regenerate 缺 conversation_id 参数,忽略" ); } } } // ── approve:ai_approve(tool_call_id, approved) ── "approve" => { // tool_call_id 必填 + approved 必填(bool),缺失跳过 match ( args_get_string(&command.args, "tool_call_id"), args_get_bool(&command.args, "approved"), ) { (Some(tool_call_id), Some(approved)) => { let _ = ai_approve(app, state, tool_call_id, approved).await; } _ => { tracing::warn!( "[remote_bridge] approve 缺 tool_call_id 或 approved 参数,忽略" ); } } } // ── authorize_dir:ai_authorize_dir(tool_call_id, decision) ── "authorize_dir" => { match ( args_get_string(&command.args, "tool_call_id"), args_get_string(&command.args, "decision"), ) { (Some(tool_call_id), Some(decision)) => { let _ = ai_authorize_dir(app, state, tool_call_id, decision).await; } _ => { tracing::warn!( "[remote_bridge] authorize_dir 缺 tool_call_id 或 decision 参数,忽略" ); } } } // ── continue_loop:ai_continue_loop(conversation_id) ── "continue_loop" => { match args_get_string(&command.args, "conversation_id") { Some(conversation_id) => { let _ = ai_continue_loop(app, state, conversation_id).await; } None => { tracing::warn!( "[remote_bridge] continue_loop 缺 conversation_id 参数,忽略" ); } } } // ── stop_loop:ai_stop_loop(conversation_id) ── "stop_loop" => { match args_get_string(&command.args, "conversation_id") { Some(conversation_id) => { let _ = ai_stop_loop(app, state, conversation_id).await; } None => { tracing::warn!( "[remote_bridge] stop_loop 缺 conversation_id 参数,忽略" ); } } } // ── switch_conversation:无对应 Tauri command(§2.2 决策 a MVP 不处理) ── // 桌面端「切会话」是纯前端操作(改 activeConversationId + 本地 store 加载历史), // 无 IPC 入口。miniapp switch 仅本地视图切换,跨端 active 不一致不阻断功能 // (事件全局广播带 conv_id,miniapp 自过滤)。列入 P4 双向同步完善。 "switch_conversation" => { tracing::info!( "[remote_bridge] switch_conversation 无对应 Tauri command(§2.2 决策 a MVP 不处理),忽略" ); } // ── list_conversations:F-#95 会话列表同步(device→miniapp 推列表) ── // 无 Tauri command 对应 —— 直接读 state.ai_conversations.list_all() 映射 ConvSummary, // 经 ai_event_bus.publish_event(AiChatEvent::AiConversationList) 跨端推回 miniapp。 // 双职责:① miniapp 会话列表渲染 ② device 在线探测(miniapp 收响应即 device 活)。 "list_conversations" => { route_list_conversations(&state).await; } // ── load_messages:F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表) ── // 无 Tauri command 对应(桌面 switch 是纯前端 + 本地 store 加载) —— 直接读 // ai_messages.list_by_conversation 映射 ChatMessage(对齐 ai_conversation_switch 读路径), // 经 publish_event(AiMessageHistory) 跨端推回 miniapp。替代 switch_conversation(后端忽略)。 "load_messages" => { match args_get_string(&command.args, "conversation_id") { Some(id) => route_load_messages(&state, id).await, None => tracing::warn!( "[remote_bridge] load_messages 缺 conversation_id 参数,忽略" ), } } // ── rename_conversation:ai_conversation_rename(conversation_id, title) + 推刷新列表 ── // miniapp 对齐桌面端改会话名。成功后 route_list_conversations 推 AiConversationList, // miniapp(及桌面端)列表 reactive 刷新见新标题。返回值忽略,结果经列表事件回流。 "rename_conversation" => { match ( args_get_string(&command.args, "conversation_id"), args_get_string(&command.args, "title"), ) { (Some(id), Some(title)) => { match ai_conversation_rename(state.clone(), id, title).await { Ok(()) => { // 推刷新列表(miniapp 据此更新会话标题) route_list_conversations(&state).await; } Err(e) => { tracing::warn!(error = %e, "[remote_bridge] rename_conversation 失败"); } } } _ => { tracing::warn!( "[remote_bridge] rename_conversation 缺 conversation_id 或 title 参数,忽略" ); } } } // ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ── // 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired, // 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令, // device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件, // miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。 "sync_pending" => { match args_get_string(&command.args, "conversation_id") { Some(id) => route_sync_pending(&state, id).await, None => tracing::warn!( "[remote_bridge] sync_pending 缺 conversation_id 参数,忽略" ), } } // ── list_skills:F-#95 跨端技能联想(device→miniapp 推本机 Claude 技能列表) ── // 调 ai_list_skills(零参,直接读进程内缓存) → publish_event(AiSkillList) 跨端透传。 // 对齐 route_list_conversations 模式(非 app.emit,publish 才跨端透传)。 "list_skills" => { route_list_skills(&state).await; } // ── list_entities:F-#95 跨端实体联想(device→miniapp 推项目/任务/灵感列表) ── // 调 list_projects/list_tasks/list_ideas(query/project_id/status 均传 None 走全量等价路径) // → 合并打包 publish_event(AiEntityList) 跨端透传,供 miniapp @ 联想浮层渲染。 "list_entities" => { route_list_entities(&state).await; } // ── 兜底臂:未知 cmd,log+忽略(不崩溃) ── // 对齐 §1.2 风险缓解「桥接层 match 末尾加兜底臂,非法命令不崩溃只记录」。 unknown => { tracing::warn!( cmd = unknown, "[remote_bridge] 未知 cmd,忽略(兜底臂,非崩溃)" ); } } } // ============================================================ // F-#95:会话列表同步路由(device→miniapp 推列表) // ============================================================ /// `list_conversations` 路由 —— 读本地会话库摘要,跨端推回 miniapp。 /// /// 流程: /// 1. `state.ai_conversations.list_all()` → `Vec` /// 2. 映射 `ConvSummary`(id/title/title 兜底空串,last_message 暂 None, /// updated_at/created_at ms 字符串 parse 为 i64) /// 3. 按 updated_at 倒序(最新在前,对齐桌面端列表展示习惯) /// 4. `state.ai_event_bus.publish_event(AiConversationList)` 跨端透传 /// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端) /// /// 双职责:① miniapp 会话列表渲染 ② device 在线探测(miniapp 收到此事件 = device 活)。 /// /// 错误兜底:list_all / parse 失败 log warn 不崩溃(空库/脏数据不影响 miniapp 连接态)。 async fn route_list_conversations(state: &State<'_, AppState>) { // 读全量会话记录(list_all 内部 ORDER BY created_at DESC,但展示口径按 updated_at 重排)。 let records = match state.ai_conversations.list_all().await { Ok(rs) => rs, Err(e) => { tracing::warn!(error = %e, "[remote_bridge] list_conversations 读会话库失败,推送空列表"); // 失败也推一次空列表(miniapp 可据此判 device 在线 + 清空本地过期缓存)。 let _ = state.ai_event_bus.publish_event(AiChatEvent::AiConversationList { conversations: Vec::new(), }); return; } }; // 映射摘要 + parse 时间戳。updated_at 缺失/脏 → None(排序兜底 0)。 let mut summaries: Vec = records .into_iter() .map(|r| ConvSummary { id: r.id, title: r.title.unwrap_or_default(), // MVP 暂不取末条消息(需解析 messages JSON,留后续批);miniapp lastMessage 渲染留空。 last_message: None, updated_at: r.updated_at.parse::().ok(), created_at: r.created_at.parse::().ok(), }) .collect(); // 按 updated_at 倒序(None 兜底 0 排末)。stable 排序保 None 项相对顺序。 summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at.unwrap_or(0))); tracing::info!( count = summaries.len(), "[remote_bridge] list_conversations 推送会话摘要列表(跨端)" ); // 跨端发布(非 app.emit)。publish_event 内部 to_value → publish 透传 tunnel subscriber。 let _ = state.ai_event_bus.publish_event(AiChatEvent::AiConversationList { conversations: summaries, }); } // ============================================================ // F-#95 扩展:历史消息同步路由(device→miniapp 推消息列表) // ============================================================ /// `load_messages` 路由 —— 按 conversation_id 加载历史消息,跨端推回 miniapp。 /// /// 对齐 `ai_conversation_switch`(conversation.rs:259-277)读路径: /// 1. `state.ai_messages.list_by_conversation(id)` → `Vec` /// 2. 映射 `Vec`(record_to_message);表空 fallback 旧 messages JSON 列(老库兼容) /// 3. `state.ai_event_bus.publish_event(AiMessageHistory)` 跨端透传 /// /// 兜底:读/解析失败 log warn 不崩溃(推送空消息列表,miniapp 可据此判 device 在线)。 async fn route_load_messages(state: &State<'_, AppState>, conversation_id: String) { let records = match state.ai_messages.list_by_conversation(&conversation_id).await { Ok(rs) => rs, Err(e) => { tracing::warn!( error = %e, conv_id = %conversation_id, "[remote_bridge] load_messages 读消息失败,推送空列表" ); let _ = state.ai_event_bus.publish_event(AiChatEvent::AiMessageHistory { conversation_id, messages: Vec::new(), }); return; } }; // 表空 → fallback 旧 messages JSON 列(对齐 ai_conversation_switch:263-277 老库兼容)。 let messages: Vec = if !records.is_empty() { records.iter().map(record_to_message).collect() } else { match state.ai_conversations.get_by_id(&conversation_id).await { Ok(Some(rec)) if rec.messages != "[]" && !rec.messages.is_empty() => { tracing::warn!( conv_id = %conversation_id, "[remote_bridge] load_messages ai_messages 表空,回退旧 messages JSON 列(老库未迁移)" ); serde_json::from_str(&rec.messages).unwrap_or_default() } _ => Vec::new(), } }; tracing::info!( conv_id = %conversation_id, count = messages.len(), "[remote_bridge] load_messages 推送历史消息(跨端)" ); let _ = state.ai_event_bus.publish_event(AiChatEvent::AiMessageHistory { conversation_id, messages, }); } // ============================================================ // 重连审批恢复:sync_pending 路由(device→miniapp 重推 conv 挂起审批) // ============================================================ /// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。 /// /// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired 事件, /// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读 /// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent /// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。 /// /// reason 泛化为「等待审批(重连恢复)」:原 reason 由 build_approval_reason 实时构造(需 risk_level /// + db 查询),PendingApproval 不存 risk_level/reason;重连恢复场景 tool_name 已在卡上,泛化可接受。 /// /// 错误兜底:读 session 失败 log warn 不崩溃(推 0 条,miniapp pending 维持清空态)。 async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String) { let approvals: Vec = { let session = state.ai_session.lock().await; session .pending_approvals .values() .filter(|p| p.conversation_id.as_deref() == Some(conversation_id.as_str())) .cloned() .collect() }; tracing::info!( conv_id = %conversation_id, count = approvals.len(), "[remote_bridge] sync_pending 重推挂起审批(跨端)" ); for p in approvals { let conv_id = Some(conversation_id.clone()); let ev = match &p.kind { ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired { id: p.tool_call_id, name: p.tool_name, args: p.arguments, reason: "等待审批(重连恢复)".to_string(), diff: diff.clone(), conversation_id: conv_id, }, ApprovalKind::Path(req) => AiChatEvent::AiDirAuthRequired { id: p.tool_call_id, tool: p.tool_name, path: req.raw_paths.first().cloned().unwrap_or_default(), dir: req .dirs .first() .map(|d| d.to_string_lossy().to_string()) .unwrap_or_default(), conversation_id: conv_id, }, }; let _ = state.ai_event_bus.publish_event(ev); } } // ============================================================ // F-#95:技能列表同步路由(device→miniapp 推本机 Claude 技能) // ============================================================ /// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。 /// /// 流程: /// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘) /// → `Vec`(已按 skills>commands>plugins 优先级去重) /// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传 /// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端) /// /// 职责:miniapp 输入框 `/` 触发技能联想浮层,对齐桌面端 `/` 联想体验。 /// /// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底) /// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。 /// /// 注:ai_list_skills 是 `#[tauri::command]` 零参函数(不需要 app/state),此处直接 async 调, /// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。 async fn route_list_skills(state: &State<'_, AppState>) { let skills: Vec = match ai_list_skills().await { Ok(s) => s, Err(e) => { tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表"); let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList { skills: Vec::new(), }); return; } }; tracing::info!( count = skills.len(), "[remote_bridge] list_skills 推送本机技能列表(跨端)" ); let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList { skills }); } // ============================================================ // F-#95:实体列表同步路由(device→miniapp 推项目/任务/灵感) // ============================================================ /// `list_entities` 路由 —— 读本地项目/任务/灵感全量列表,跨端推回 miniapp。 /// /// 流程: /// 1. 并行顺序调三 Tauri command(query/project_id/status 均传 None 走 list_active/list_all /// 全量等价路径):list_projects(state, None) / list_tasks(state, None, None) / /// list_ideas(state, None, None) /// 2. 合并打包成 `AiEntityList { projects, tasks, ideas }` 经 publish_event 跨端透传 /// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端) /// /// 职责:miniapp 输入框 `@` 触发实体联想浮层(选项目/任务/灵感),对齐桌面端 @ 体验。 /// 全量字段(对齐桌面端 src/api/types.ts);带宽敏感裁剪留后续批,本批不动。 /// /// 错误兜底:任一 command 失败 → 该实体降级为空 Vec(不阻断其余实体),最终 publish_event /// 推含成功的实体(可能部分空)。对齐 route_list_conversations 失败推空列表语义(部分失败 /// 不致整个路由崩溃,miniapp 拿到部分列表仍可用)。 /// /// 注:三 Tauri command 第二参是 `State<'_, AppState>`,本路由收 `&State`,调时传 /// `state.clone()`(tauri::State Clone,对齐 route_send_message:468-478 ai_chat_send 调用模式)。 /// 不传 query/project_id/status 即走 list_active/list_by_query 空 query 全量等价路径。 async fn route_list_entities(state: &State<'_, AppState>) { // list_projects(None query) → list_active 全量(deleted_at IS NULL + created_at DESC)。 let projects = match list_projects(state.clone(), None).await { Ok(ps) => ps, Err(e) => { tracing::warn!(error = %e, "[remote_bridge] list_entities list_projects 失败,降级空 Vec"); Vec::new() } }; // list_tasks(None project_id, None query) → 全量未删任务(等价改造前 list_active)。 let tasks = match list_tasks(state.clone(), None, None).await { Ok(ts) => ts, Err(e) => { tracing::warn!(error = %e, "[remote_bridge] list_entities list_tasks 失败,降级空 Vec"); Vec::new() } }; // list_ideas(None status, None query) → list_by_query 空 query(等价 list_all,created_at DESC)。 let ideas = match list_ideas(state.clone(), None, None).await { Ok(is) => is, Err(e) => { tracing::warn!(error = %e, "[remote_bridge] list_entities list_ideas 失败,降级空 Vec"); Vec::new() } }; tracing::info!( projects = projects.len(), tasks = tasks.len(), ideas = ideas.len(), "[remote_bridge] list_entities 推送项目/任务/灵感列表(跨端)" ); let _ = state.ai_event_bus.publish_event(AiChatEvent::AiEntityList { projects, tasks, ideas, }); } // ============================================================ // R1 兜底:send_message 路由前置 generating 检查 // ============================================================ /// `send_message` 路由 —— R1 兜底前置 generating 检查后调 ai_chat_send。 /// /// R1(§3.3 + D6,硬性不可省):miniapp 与桌面同时给同 conv 发消息时, /// ai_chat_send 内部虽有 generating guard,但桥接层再加一层前置检查作为双保险 /// (防御性编程,对齐设计文档 D6「桥接层 send 路由硬性加,无论 ai_chat_send 内部是否有 guard」)。 /// /// 检查口径与 `ai_is_generating` 完全一致(双轨): /// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(含 Generating / Compressed 派生态) /// - off → 回退 `generating` bool /// /// generating=true 则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。 /// (事件透传阶段2 已建通路,miniapp handleEvent 能渲染 AiError,用户可见反馈)。 /// /// message 必填,缺失跳过(ai_chat_send 无 message 无意义,日志告警)。 async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: Value) { // 取 message(必填) + conversation_id(可选,空字符串规整为 None) + model_override(可选)。 let message = match args_get_string(&args, "message") { Some(m) => m, None => { tracing::warn!("[remote_bridge] send_message 缺 message 参数,忽略"); return; } }; let conversation_id = args_get_string(&args, "conversation_id"); let model_override = args_get_string(&args, "model_override"); // F-#95 跨端技能/mention 透传:miniapp 选技能(/ 联想) + @ mention 区间透传 ai_chat_send, // 让后端注入技能正文 + 解析 mention(对齐桌面端 ai_chat_send chat.rs:300-320 调用)。 // skill:技能名(Option,空字符串规整为 None);mention_spans:@ 区间(失败兜底 None)。 let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty()); let mention_spans = args_get_mention_spans(&args, "mention_spans"); // ── R1 前置 generating 检查(双轨口径对齐 ai_is_generating) ── // 目标 conv:入参 conversation_id 优先 → active conv 兜底(与 ai_is_generating 同口径)。 // 空目标(无 conv_id 且无 active):无任何 conv 在跑,放行。 if let Some(reject_conv_id) = check_generating_reject(state, conversation_id.as_deref()).await { // generating=true:拒绝,emit AiError 回 miniapp 提示。 // conv_id 透传原 conversation_id(用户当前面板的 conv),便于 miniapp 按 conv 路由展示。 tracing::info!( conv_id = ?reject_conv_id, "[remote_bridge][R1] 同 conv 正在生成中,拒绝远程 send_message(emit AiError 回 miniapp)" ); let _ = app.emit( "ai-chat-event", AiChatEvent::AiError { error: "正在生成中,请等待完成".to_string(), // 并发拒绝非错误源分类(auth/network/timeout/provider_config),用 Unknown 兜底。 error_type: Some(crate::commands::ai::ErrorType::Unknown), conversation_id: Some(reject_conv_id), }, ); return; } // ── 放行:调 ai_chat_send(其余可选参数 language/parts 不经远程;skill + mention_spans // 经远程透传以支持 miniapp 的 / 技能联想 + @ mention 体验,对齐桌面端 ai_chat_send) ── // // F 核验(2026-06-22):ai_chat_send 内部已有 generating guard(chat.rs:354-358)。 // R1 前置检查是双保险(任务原文要求"无论 ai_chat_send 内部是否有 guard"都加)。 // 这里额外把 ai_chat_send 返回的 Err 转 AiError emit 回 miniapp —— // 无论 R1 命中(理论上不会到这)/ 内部 guard 命中 / Provider 配置错 / 参数错, // 用户都能在 miniapp 看到 AiError 反馈(否则命令返 Err 静默,miniapp 不知失败)。 // F-260622-02 跨端用户消息同步:调 ai_chat_send 前广播 AiUserMessage 到 ai_event_bus, // 让桌面 useAiEvents 收到并补 user 气泡(否则桌面只看到孤立的 assistant 响应气泡)。 // 用 publish_event(跨端透传,与 route_list_conversations:335 同通路),非 app.emit // (emit 仅桌面前端,publish 经 EventBus→tunnel subscriber,桌面端自身也是订阅者能收到)。 // 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。 // 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。 let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage { message: message.clone(), conversation_id: conversation_id.clone(), }); let result = ai_chat_send( app.clone(), state.clone(), message, None, // language:远程默认不传,ai_chat_send 内部 fallback "zh-CN" skill, // skill:miniapp / 联想选中技能名透传(技能正文注入) model_override, // model_override:远程透传用户选择 conversation_id.clone(),// conversation_id:远程透传目标 conv None, // parts:多模态片段,远程 MVP 不传 mention_spans, // mention_spans:@ mention 区间透传(对齐桌面端 ai_chat_send) ) .await; if let Err(err_msg) = result { tracing::warn!( error = %err_msg, conv_id = ?conversation_id, "[remote_bridge] ai_chat_send 返回 Err,转 AiError emit 回 miniapp" ); let _ = app.emit( "ai-chat-event", AiChatEvent::AiError { error: err_msg, // ai_chat_send 的 Err 多为 generating 拦截 / Provider 配置缺失, // 难精确分类,用 Unknown 兜底(对齐 mod.rs ErrorType 语义)。 error_type: Some(crate::commands::ai::ErrorType::Unknown), conversation_id, }, ); } } /// R1 检查:目标 conv 是否正在生成。返 `Some(conv_id)` 表示拒绝(该 conv 在跑), /// 返 `None` 表示放行(可发)。 /// /// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293): /// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃, /// 压缩期间 loop 仍活跃) /// - off → 回退 `generating` bool /// /// 目标 conv 解析(对齐 ai_is_generating:278-284): /// - 入参 conversation_id 非空优先 /// - 否则 fallback `active_conversation_id` /// - 都无 → 返 None(放行,无 conv 在跑) /// /// 读 conv 状态用 `conv_read`(只读不创建),对齐 ai_is_generating 实现。 async fn check_generating_reject( state: &State<'_, AppState>, conversation_id: Option<&str>, ) -> Option { let session = state.ai_session.lock().await; // 目标 conv:入参优先 → active 兜底。 let target = conversation_id .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .or_else(|| session.active_conversation_id.clone()); let target = match target { Some(id) => id, None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行 }; // 双轨读 generating(对齐 ai_is_generating,无 conv_read 视为 false 放行)。 let is_gen = session .conv_read(&target) .map(|c| { if CONV_STATE_ENABLED { c.conv_state.is_active() } else { c.generating } }) .unwrap_or(false); if is_gen { Some(target) } else { None } } // ============================================================ // args 字段提取 helper(从 serde_json::Value 稳健取值) // ============================================================ /// 从 args 取 string 字段。非 string / 缺失 → None(调用方各自处理缺失语义)。 fn args_get_string(args: &Value, key: &str) -> Option { args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) } /// 从 args 取 bool 字段。非 bool / 缺失 → None。 fn args_get_bool(args: &Value, key: &str) -> Option { args.get(key).and_then(|v| v.as_bool()) } /// 从 args 取 mention_spans 字段(数组)反序列化为 `Vec`。 /// /// 用于 route_send_message 透传 miniapp @ mention 区间到 ai_chat_send。对齐 /// ai_chat_send 第 9 参 `Option>`(chat.rs:319)。 /// /// 缺失 / 非数组 / 元素反序列化失败 → None(对齐 miniapp MVP 无 mention 走 None 的旧路径, /// 不因 mention_spans 脏数据阻断 send_message 路由)。失败仅 log warn,不 panic。 fn args_get_mention_spans(args: &Value, key: &str) -> Option> { match args.get(key) { Some(Value::Array(_)) => match serde_json::from_value::>(args[key].clone()) { Ok(spans) => { if spans.is_empty() { None } else { Some(spans) } } Err(e) => { tracing::warn!( error = %e, "[remote_bridge] mention_spans 反序列化失败,降级 None(不阻断 send)" ); None } }, // 缺失或非数组(null/string 等) → None(向后兼容无 mention 的远程调用)。 _ => None, } } // ============================================================ // 单元测试 // ============================================================ // // 覆盖: // 1. MiniCommand 反序列化(标准结构 / args 缺省 / 非 Object payload 失败) // 2. match 各 cmd 臂的路由分支(send_message R1 拒绝 / stop / regenerate / approve / // authorize_dir / continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略) // 3. R1 拒绝路径(generating=true 时 emit AiError 不调 ai_chat_send) // // 注:完整 Tauri command 调用链(需 AppHandle + State 真实构造)不在单元测试范围, // 用 helper 函数(check_generating_reject / args_get_* / MiniCommand::from_payload) // 隔离测试核心路由逻辑,绕开 Tauri runtime 依赖。 #[cfg(test)] mod tests { use super::*; use crate::commands::ai::{AiSession, PerConvState}; use df_ai::context::ContextConfig; // ── MiniCommand 反序列化测试 ── /// 标准结构 `{cmd, args}` 反序列化成功。 #[test] fn test_mini_command_deserialize_standard() { let payload = serde_json::json!({ "cmd": "send_message", "args": { "message": "你好", "conversation_id": "conv-123" } }); let cmd = MiniCommand::from_payload(&payload).expect("标准结构应反序列化成功"); assert_eq!(cmd.cmd, "send_message"); assert_eq!( cmd.args.get("message").and_then(|v| v.as_str()), Some("你好") ); assert_eq!( cmd.args.get("conversation_id").and_then(|v| v.as_str()), Some("conv-123") ); } /// args 缺省时回退空 Object(防御性 #[serde(default)])。 #[test] fn test_mini_command_deserialize_args_default() { let payload = serde_json::json!({"cmd": "stop"}); let cmd = MiniCommand::from_payload(&payload).expect("args 缺省应回退空 Object"); assert_eq!(cmd.cmd, "stop"); assert!(cmd.args.is_object(), "args 缺省应为空 Object"); assert!(cmd.args.get("conversation_id").is_none()); } /// 非 Object payload(如纯字符串 / 数组)反序列化失败。 #[test] fn test_mini_command_deserialize_non_object_fails() { let payload = serde_json::json!("just a string"); assert!( MiniCommand::from_payload(&payload).is_err(), "非 Object payload 应反序列化失败" ); } /// 缺 cmd 字段反序列化失败(cmd 必填)。 #[test] fn test_mini_command_deserialize_missing_cmd_fails() { let payload = serde_json::json!({"args": {"foo": "bar"}}); assert!( MiniCommand::from_payload(&payload).is_err(), "缺 cmd 字段应反序列化失败" ); } // ── args 字段提取 helper 测试 ── /// args_get_string / args_get_bool 各类型分支。 #[test] fn test_args_helpers() { let args = serde_json::json!({ "str_field": "hello", "bool_field": true, "num_field": 42, "null_field": null, }); // string 字段命中 assert_eq!( args_get_string(&args, "str_field"), Some("hello".to_string()) ); // 缺失字段返 None assert_eq!(args_get_string(&args, "missing"), None); // 非 string 字段(number)返 None assert_eq!(args_get_string(&args, "num_field"), None); // null 字段返 None(as_str 对 null 返 None) assert_eq!(args_get_string(&args, "null_field"), None); // bool 字段命中 assert_eq!(args_get_bool(&args, "bool_field"), Some(true)); // 非 bool 字段返 None assert_eq!(args_get_bool(&args, "str_field"), None); // 缺失 bool 返 None assert_eq!(args_get_bool(&args, "missing"), None); } // ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ── // // check_generating_reject 需 `State`,单元测试难构造。 // 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟 generating 状态, // 复核 conv_read + CONV_STATE_ENABLED 口径对齐 ai_is_generating。 /// R1 判定:无目标 conv 且无 active → 放行(返 None)。 #[test] fn test_r1_no_target_pass() { let session = AiSession::new(); // 无 active,无入参 conv_id → 无任何 conv 在跑 assert!( session.active_conversation_id.is_none(), "新 session 无 active conv" ); assert!( session.per_conv.is_empty(), "新 session per_conv 为空" ); } /// R1 判定:目标 conv 未在跑 → 放行(generating=false)。 #[test] fn test_r1_target_idle_pass() { let mut session = AiSession::new(); let conv = session.conv("conv-idle"); conv.generating = false; // 读 generating 口径(对齐 check_generating_reject / ai_is_generating) let is_gen = session .conv_read("conv-idle") .map(|c| { if CONV_STATE_ENABLED { c.conv_state.is_active() } else { c.generating } }) .unwrap_or(false); assert!(!is_gen, "conv-idle 未在跑,is_gen 应为 false(放行)"); } /// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。 #[test] fn test_r1_target_generating_reject() { use crate::commands::ai::agentic::conv_state::ConvState; let mut session = AiSession::new(); let conv = session.conv("conv-busy"); // 双轨一致:CONV_STATE_ENABLED on 时 R1 读 conv_state.is_active()(非 generating bool), // 须同步设 conv_state=Generating(对齐 test_r1_conv_state_active_reject 设态范式) conv.conv_state = ConvState::Generating; conv.generating = true; let is_gen = session .conv_read("conv-busy") .map(|c| { if CONV_STATE_ENABLED { c.conv_state.is_active() } else { c.generating } }) .unwrap_or(false); assert!(is_gen, "conv-busy generating=true 应拒绝"); } /// R1 判定:conv_state 派生态(Generating)is_active()=true(双轨 on 路径)。 /// /// 验证 CONV_STATE_ENABLED on 时读 conv_state.is_active() 而非 generating bool —— /// 即便 generating bool 误为 false 但 conv_state=Generating,仍应判定活跃拒绝。 #[test] fn test_r1_conv_state_active_reject() { use crate::commands::ai::agentic::conv_state::ConvState; // CONV_STATE_ENABLED 是编译期 const(true),本测试假定 on 路径。 // 若未来开关改 runtime,此测试需条件跳过 on 时才跑。 let mut session = AiSession::new(); let conv = session.conv("conv-state-busy"); // 模拟状态机写收敛路径:conv_state=Generating,generating bool 同步 true(双轨一致) conv.conv_state = ConvState::Generating; conv.generating = true; let is_gen = session .conv_read("conv-state-busy") .map(|c| { if CONV_STATE_ENABLED { c.conv_state.is_active() } else { c.generating } }) .unwrap_or(false); assert!( is_gen, "conv_state=Generating is_active() 应为 true(双轨 on 路径拒绝)" ); } /// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。 #[test] fn test_r1_target_input_priority() { let mut session = AiSession::new(); session.active_conversation_id = Some("conv-active".to_string()); // active 在跑,但入参 conv-other 未跑 → 入参优先,应放行 session.conv("conv-active").generating = true; let _ = session.conv("conv-other"); // 创建但不 generating let active_gen = session .conv_read("conv-active") .map(|c| c.generating) .unwrap_or(false); let other_gen = session .conv_read("conv-other") .map(|c| c.generating) .unwrap_or(false); assert!(active_gen, "conv-active 在跑"); assert!(!other_gen, "conv-other 未跑"); // 入参 conv-other 优先 → 判定 conv-other.generating=false → 放行 // (与 check_generating_reject 入参优先逻辑一致) } // ── PerConvState 初值对齐(确保 R1 测试基线 generating=false) ── /// PerConvState::new() generating 初值 false(R1 放行基线)。 #[test] fn test_per_conv_state_generating_default_false() { let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused) let s = PerConvState::new(); assert!(!s.generating, "PerConvState::new generating 初值应为 false"); } }