Files
DevFlow/docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md

30 KiB

F-260622-01 跨端 AI Chat Phase3 联调设计(协议统一 + 桥接方案)

创建:2026-06-22 | 状态:📐 设计草案(纯设计,未实施任何代码) 上级索引:../INDEX.md | 续篇:F-260620-01-跨端AIChat-微信小程序-2026-06-20.md

〇、定位与术语澄清(必读)

本文档是 F-260620-01 的 Phase3 实施续篇,专注「三层各自就绪后,如何把它们接线联调」的设计。

术语对齐(避免与 F-260620-01 已有阶段命名冲突):

术语 在 F-260620-01 的含义 在本文档的含义
P1 / P2 / P3 / P4 df-relay / df-tunnel / df-miniapp / 双向同步完善 —(本文不重定义)
Phase3 —(未用) 三层联调总称:协议统一 + 事件桥接 + 指令路由 + 真机联调

已就绪状态(核验结论,只读 grep 得出):

状态 commit 协议契约(当前形态)
df-relay Phase2 2b8b30e 透传不解析 payload(serde_json::Value),按 kind 路向:Event(Device→Miniapp)/ Command(Miniapp→Device)/ Control
df-tunnel Phase2 25d6565 TunnelCommand{kind,...}(#[serde(tag="kind")] 5 变体:Send/Stop/Approve/Regenerate/Switch);发 TunnelEvent(4 变体子集)
df-miniapp 280baea MiniCommand{cmd, args}(cmd 对齐 Tauri command 名,如 "send_message";args.message/args.conversation_id)

核心阻断问题:relay 透传不转换,miniapp 发 MiniCommand,tunnel 收 TunnelCommand —— 两端序列化结构不匹配,联调在协议层即阻断。本文第一节即决策如何统一。


一、协议统一决策(A / B,论证给推荐)

1.1 不匹配点逐项对照(源码核验)

miniapp 端 apps/df-miniapp/src/types/relay.ts:88-93 + useAiChat.ts:379-385:

interface MiniCommand { cmd: string; args: Record<string, unknown> }
// 实际发送:{ cmd: "send_message", args: { message, conversation_id } }

tunnel 端 crates/df-tunnel/src/events.rs:119-132:

#[serde(tag = "kind", rename_all = "snake_case")]
enum TunnelCommand {
    Send { conv_id: String, content: String },
    Stop { conv_id: String },
    Approve { conv_id: String, approval_id: String, accepted: bool },
    Regenerate { conv_id: String },
    Switch { conv_id: String },
}

三重不匹配:

维度 miniapp(发) tunnel(收) 不匹配
外层结构 {cmd, args} 两字段 {kind, ...} 内联(tag enum) 结构不匹配
标签词 cmd:"send_message" kind:"send" 词汇不匹配(send_message vs send)
字段名 args.message / args.conversation_id content / conv_id 字段名不匹配
审批/重试 未发(cmd 仅 3 条 MVP) Approve/Regenerate 已定义 覆盖面不匹配

tunnel 的 parse_command_from_broadcast(tunnel.rs:439-455)对 payload 做 serde_json::from_value::<TunnelCommand> —— miniapp 的 {cmd, args} 反序列化为 TunnelCommand 必然失败(无 kind 字段、cmd"send_message" 不在 5 变体内)。联调第一步就被协议层挡死。

1.2 方案 A:tunnel 改纯透传 + device 端 src-tauri 桥接层解协议

做法:

  • df-tunnel 不再定义强类型 TunnelCommand,入站回调改为 on_raw_command(payload: serde_json::Value)(或保留 TunnelCommand 但加一条「payload 原样上抛」的兜底分支)。
  • tunnel 退回纯传输层,只认「这是 Miniapp→Device 的 Command 方向」,不解析 payload 语义。
  • 真正的协议解析在 device 端 src-tauri 新增的桥接层:收到 Value 后,按 miniapp 约定的 {cmd, args} 反序列化,match cmd { "send_message" => ai_chat_send(...), "stop" => ai_chat_stop(...), ... } 路由到现有 Tauri command。

论证:

维度 评价
架构原则(no-patch-groundwork) 强对齐。relay/tunnel 是传输层,职责是「把字节从 A 搬到 B」,业务协议(命令名、字段名)属于业务层。让传输层认 kind:"send" 是越界耦合 —— tunnel crate 不该知道「send 是什么意思」。把协议知识下沉到 tunnel 等于把业务逻辑泄露进传输层,违反单一职责。
改动面 tunnel:删/弱化 TunnelCommand 强类型解析,改 on_command 签名(~30 行);miniapp:零改动(继续发 MiniCommand);src-tauri:新增桥接模块(~150 行,纯新增)。
device 桥接复杂度 中。桥接层是「MiniCommandTauri command 调用」的翻译表,本质是 match 表,新增命令只加一行 match 臂。
扩展性 。miniapp 新增命令只动 miniapp + 桥接层 match 表,tunnel/relay 永不需要改。符合「两端同语言、中间无业务」的 F-260620-01 技术选型初衷(Rust 优势在两端类型一致,中间透传)。
向后兼容 tunnel 不再定义命令类型,未来协议字段变更不波及 tunnel。
与现有 Tauri 命令对齐 天然对齐MiniCommand.cmd 直接复用 Tauri command 函数名(ai_chat_send 等),桥接层几乎是 1:1 invoke。miniapp 已按此设计(relay.ts:82 注释「对齐 Tauri command 名」)。

1.3 方案 B:miniapp 改发 TunnelCommand 格式

做法:

  • miniapp 端把 MiniCommand{cmd, args} 改成 TunnelCommand{kind, ...},字段名对齐 tunnel(conv_id/content)。
  • tunnel 端零改动,继续强类型解析。

论证:

维度 评价
架构原则 ⚠️ 偏离。等于承认「tunnel 定义的业务协议就是跨端协议」,传输层握有协议定义权。但 tunnel crate 文档明确写「不依赖 src-tauri/df-types(避跨 crate 强耦合)」(events.rs:5-7)—— 它的 TunnelCommand 是独立镜像,不是权威契约。让 miniapp 对齐一个非权威的镜像契约,契约源头混乱。
改动面 miniapp:relay.ts + useAiChat.ts + 可能 events.ts 全改(~80 行);tunnel:零改动;src-tauri:仍需桥接层(把 TunnelCommand::Send{conv_id, content} 翻译成 ai_chat_send(message=content, conversation_id=conv_id))—— 桥接层省不掉,只是输入类型变了。
device 桥接复杂度 中。仍是 match 翻译,但要做 conv_id↔conversation_idcontent↔message 字段名映射(tunnel 用简写 conv_id,src-tauri 用全称 conversation_id)。
扩展性 ⚠️ 。每加一条命令要同时改 tunnel 的 TunnelCommand 枚举 + miniapp + 桥接层三处,tunnel 被迫随业务膨胀,违背它「轻量传输」定位。
向后兼容 ⚠️ tunnel TunnelCommand 一旦发布就是协议的一部分,后续字段重命名(conversation_id 全称统一?)会破坏 miniapp。
与 Tauri 命令对齐 不对齐kind:"send" ≠ Tauri ai_chat_send,conv_idconversation_id。桥接层要做双重翻译(协议词 + 字段名)。

1.4 推荐:方案 A

理由(按权重排序):

  1. no-patch-groundwork 元原则(最高权重):relay/tunnel 是传输层,纯透传是它的天职。当前 tunnel 强类型 TunnelCommand 是 Phase2 实现「先跑通握手」时的便利设计,但它在架构上是业务知识下沉到传输层的技术债。Phase3 联调正是清这笔债的时机 —— 要么现在清,要么以后协议每次扩张都拖 tunnel 一起改,债越滚越大。
  2. 契约源头唯一:权威业务协议在 src-tauri(Tauri command 即事实标准),miniapp 已主动对齐(relay.ts:82)。方案 A 让 tunnel 也对齐这个源头(透传即对齐「 whatever 两端约定」),三方契约源头唯一。方案 B 会造出第二个源头(tunnel 的 TunnelCommand)。
  3. 改动面更小且更安全:方案 A 的 tunnel 改动是「弱化解析」(删类型比加类型安全,删错最多漏一条命令,加错会阻断全部),miniapp 零改动,src-tauri 新增纯加法。方案 B 要改 miniapp 三个文件,回归风险更高。
  4. 桥接层省不掉,方案 A 让它更简单:无论 A/B,device 端都要把「线上协议」翻译成「Tauri command 调用」。方案 A 的输入是 {cmd, args},cmd 已是 Tauri 函数名,翻译近乎恒等;方案 B 还要额外做 send↔ai_chat_sendconv_id↔conversation_id 词表映射。

风险与缓解:

  • 风险:方案 A 后 tunnel 失去「编译期校验命令合法性」的能力,非法命令要到运行时桥接层才报错。
  • 缓解:桥接层 match 末尾加兜底臂 _ => log+忽略,非法命令不崩溃只记录;miniapp 端 TS 类型(MiniCommand)仍提供编译期校验(前端侧),tunnel 侧的运行时校验丢失可接受(它本就不该是校验点)。

决策点(留待主代理/用户定):本节给推荐 A,但 A/B 均合理。若团队更看重「tunnel 零改动、协议在 miniapp 侧收敛」可定 B。一旦定 A,tunnel 的 TunnelCommand 是否彻底删除 vs 保留作可选强类型校验层(对常见命令做 best-effort 解析,失败回落透传)是次级决策,建议保留作弱校验(向后兼容更好)。


二、src-tauri AiSession 桥接设计(方案 A 假定下)

桥接分两个方向:Event 上行透传(device→miniapp)、Command 下行路由(miniapp→device)。

2.1 Event 上行:把 AiChatEvent 经 tunnel 推给 miniapp

现状核验

  • AiChatEvent 19 变体定义于 src-tauri/src/commands/ai/mod.rs:104-216(变体清单见附录 A)。
  • 事件经 app_handle.emit("ai-chat-event", AiChatEvent::...) 全局广播,事件名硬编码散布在 7 个文件 55 处(event_bus.rs / agentic/mod.rs / agentic/guard.rs / mod.rs / commands/chat.rs / stream_recv.rs / audit/mod.rs)。
  • 前端(src/api/ai.ts:258)通过 listen<AiChatEvent>('ai-chat-event', ...) 单一事件名订阅,按 event.type discriminator 分派。
  • 事件携带 conversation_id: Option<String> 字段做多会话路由。

桥接注入点选择(关键设计)

问题:55 处 emit 散布各处,逐处加 tunnel 推送 = 55 处改动 + 必然遗漏。需要单一汇聚点。

选项对比:

注入点 做法 评价
(a) 逐 emit 点 clone 在每处 app.emit 旁加 tunnel.send_event(...) 55 处改动,违反 DRY,遗漏必然
(b) Tauri 事件拦截 注册一个全局 on_event 监听 ai-chat-event,收到后转发 tunnel ⚠️ 在 src-tauri 内部 listen 自己 emit 的事件,绕一圈(后端→前端事件总线→后端),不优雅,且 Tauri 的 emit 是给前端的,后端监听要 app.listen API
(c) EventBus 汇聚(推荐) 接入现有 event_bus.rs 骨架(已建未接),把 55 处 emit 改为 event_bus.publish(...) 一处汇聚,EventBus 内部同时做 app.emit + tunnel.send_event 单一汇聚点,tunnel 作为一个 subscriber 接入,符合 EventBus 设计初衷(event_bus.rs:6-7 注释明确「散布各处无统一总线」就是为汇聚而建)
(d) 新建 tunnel gate 函数 包装 emit_ai_event(app, tunnel, event),55 处改调它 也汇聚,但与 EventBus 重复造轮子;EventBus 已是「为这件事」建的基础设施

推荐 (c):接入 EventBus。理由:

  1. EventBus 是专门为此场景建的基础设施(event_bus.rs 文档自述),当前是骨架「批2接入 emit 点」未做 —— Phase3 桥接正好是它第一批真实 subscriber。
  2. tunnel 作为 EventBus 的一个 subscriber(EventBus::subscribe 返回 EventSubscriber),EventBus publish 时 fan-out 到所有 subscriber。tunnel 的 subscriber 收到事件后 send_event(TunnelEvent::...)
  3. 与「全局事件数据总线」专项设计文档(docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md)阶段 6「跨端透传 df-tunnel adapter」完全对齐 —— 不是新造,是落地该规划的子集。
  4. EVENT_BUS_ENABLED 开关(event_bus.rs:42)天然提供回退:桥接出问题关开关,原 emit 路径不受影响(event_bus.rs:45 注释明示此兜底语义)。

哪些事件变体需要透传(子集决策)

miniapp MVP 已实现 handleEvent(useAiChat.ts:100-314)处理 18 个变体中的多数。但 tunnel 端 TunnelEvent(events.rs:63-101)只定义了 4 个变体(TextDelta/ToolCall/Approval/Completed)。方案 A 下 miniapp 收的是「relay 透传的 AiChatEvent 原样 JSON」,不依赖 tunnel 的 TunnelEvent 强类型 —— 所以 tunnel 也应纯透传 AiChatEvent payload,不再裁剪到 4 变体

推荐:全 19 变体透传(不裁剪),理由:

  • miniapp handleEvent 已能处理绝大多数变体,裁剪反而让某些事件(如 AiCompressed/AiHelpRequired)在 miniapp 永远收不到。
  • 全透传 = tunnel 零业务知识(对齐方案 A 原则)。
  • 带宽:19 变体大多是低频(AiCompleted/AiError 每轮一次),只有 AiTextDelta 高频但单条小。MVP 阶段全透传带宽无忧。

实现要点(设计,非代码):

  • tunnel send_event 入参类型从 TunnelEvent 放宽为 serde_json::Value(或新增 send_raw_event(Value))。
  • EventBus subscriber 在 src-tauri 桥接层:把 AiChatEvent 序列化为 Value → tunnel.send_raw_event(value) → tunnel 写 socket → relay 透传 → miniapp onEvent(msg)msg.payload as AiChatEvent 直接用(useAiChat.ts:333 已是此假设)。
  • TunnelEvent 强类型 4 变体(events.rs:63-101)保留作可选,不删除 —— 它可作为「高频路径的强类型快捷方式」或测试断言用,删它是方案 A 的过度演绎(对齐 dead-code-reserve-keep 原则:零调用方的预留保留)。

AiChatEvent 全局广播 vs 路由

当前 emit 是全局广播(无 conv 过滤)。miniapp 单会话视图只关心 activeConversationId 的事件(useAiChat.ts:109 按 isCurrent 过滤)。透传策略:

  • 透传全部,miniapp 侧过滤(推荐):tunnel 不做 conv 过滤,miniapp 收到后按 event.conversation_id 自行过滤。简单、对齐现状、不丢事件。
  • tunnel 侧按 conv 过滤:需 tunnel 知道「miniapp 当前看哪个 conv」—— 引入反向状态同步,复杂度高,MVP 不做。

2.2 Command 下行:收到 MiniCommand 路由到 Tauri command

桥接层设计

device 端 src-tauri 新增桥接模块(设计上,如 commands/ai/remote_bridge.rs 或挂在 tunnel 初始化处),注册为 tunnel 的 on_command 回调(方案 A 下回调签名收 serde_json::Value)。

路由表(MiniCommand.cmd → Tauri command,基于 src-tauri 现有命令清单核验,附录 B):

MiniCommand.cmd args 字段 调用的 Tauri command 命令签名(附录 B 摘要)
"send_message" {message, conversation_id, model_override?} ai_chat_send (app, state, message, language?, skill?, model_override?, conversation_id?, parts?, mention_spans?)
"stop" {conversation_id} ai_chat_stop (state, app, conversation_id?)
"switch_conversation" {conversation_id} (无直接命令,见下)
"regenerate" {conversation_id} ai_regenerate (app, state, conversation_id, language?, model_override?)
"approve" {tool_call_id, approved} ai_approve (app, state, tool_call_id, approved)
"authorize_dir" {tool_call_id, decision} ai_authorize_dir (app, state, tool_call_id, decision)
"continue_loop" {conversation_id} ai_continue_loop (app, state, conversation_id)
"stop_loop" {conversation_id} ai_stop_loop (app, state, conversation_id)

关键缺口:switch_conversation 无对应 Tauri command

核验发现:src-tauri 没有 ai_switch_conversation 命令。miniapp(useAiChat.ts:439)发 switch_conversation 但桌面端无对应入口。

原因分析:桌面端的「切换会话」是纯前端操作(改 activeConversationId 状态 + 从本地 store 加载消息历史),不经 Tauri command —— 因为真相源在桌面本地,切会话只是视图切换。

跨端影响:miniapp 切会话时,桌面端若不同步,两边 activeConversationId 不一致;但 AI 事件透传是全局广播(附 conv_id),miniapp 按 conv_id 自过滤,功能上不阻断。差异只在「桌面端 UI 高亮哪条会话」。

设计建议(留待主代理定):

  • (a) 不处理:miniapp switch 仅本地视图切换,不通知桌面。桌面 UI 与 miniapp 各自维护 active。简单,但两端 active 可能不一致。
  • (b) 加一个轻量 sync command:新增 ai_sync_active_conversation(app, state, conversation_id),仅更新 AiSession.active_conversation_id(mod.rs:350),不触发其他逻辑。供桌面 UI 监听同步高亮。
  • 推荐 (a) 起步(MVP 不阻断),(b) 列入 P4 双向同步完善(F-260620-01 已留 P4 阶段)。

on_command 回调签名适配(方案 A)

tunnel 当前 CommandHandler = Arc<dyn Fn(TunnelCommand) -> BoxFuture<()>>(tunnel.rs:42)。方案 A 下需放宽为收 serde_json::Value:

CommandHandler = Arc<dyn Fn(serde_json::Value) -> BoxFuture<()>>

桥接层回调内部:

  1. 尝试反序列化 MiniCommand{cmd, args}(失败 → log+忽略,可能 miniapp 发了未知格式)。
  2. match cmd { ... } 路由到对应 Tauri command 函数(直接 async 调用,因为桥接层在 src-tauri 内,有 app+state 句柄)。
  3. command 返回值忽略(结果经 AiChatEvent 回流,不走 command 返回值)。

注意:Tauri command 函数签名第一参数是 AppHandle / State<AppState>,桥接层需在初始化时持有这俩句柄(从 tunnel 启动处 clone 进闭包)。这是标准 Tauri 模式,无新风险。


三、AiSession 单例并发模型桥接风险(重点)

桥接最大风险不在协议,而在 miniapp 远程指令与桌面本地操作并发撞击 AiSession 单例状态。核验结论(mod.rs:348-383):

3.1 现状:F-09 决策 e 已落地真多会话并发

  • AiSession.per_conv: HashMap<String, PerConvState>(mod.rs:364)是唯一真相源,顶层单例字段(messages/generating/stop_flag)已删除。
  • 每个 conv 独立 generating: bool + stop_flag: Arc<AtomicBool> + notify: Arc<Notify>(PerConvState,mod.rs:632-716)。
  • pending_approvals: HashMap<String, PendingApproval>(mod.rs:358)单表,带 conversation_id 字段路由。
  • 真支持多会话并行生成(commit 126bee5 「F-09生成中新建对话不中断」已去中断弹窗 + 死 key 清理)。

这是跨端桥接的利好:miniapp 操作 conv-X 时,桌面用户可在 conv-Y 继续操作,互不中断。conv_id 路由天然隔离。

3.2 风险点清单

# 风险 场景 影响 缓解
R1 同 conv 并发发送 miniapp 与桌面同时给 conv-X 发消息 ai_chat_send 内部若未做「generating 中拒绝」校验,可能触发两路 agent loop 抢同一 PerConvState 核验:ai_chat_send 应有 generating guard(对照 desktop useAiSend 发送前检查)。桥接层额外加 if conv_read(conv).generating { 拒绝+回错事件 } 兜底。
R2 审批双端竞态 miniapp 与桌面同时批/拒同一 tool_call_id ai_approve 两次调用,pending_approvals 第二次取不到 → 返回错;但 try_continue_agent_loop 可能被触发两次 核验 ai_approve 是否幂等(取走即删);桥接层不额外加锁(依赖 command 内部 HashMap 操作的原子性,Tauri command 在 state 锁内串行)。低风险。
R3 审批阻塞 + 远程指令堆积 conv-X 卡在 AwaitingApproval,miniapp 持续发 send/stop stop 命令对 AwaitingApproval 状态的语义不清(停止生成 vs 停止等待审批?) 桥接层对 stop 命令加状态判断:if session_state(conv) == AwaitingApproval { 走 ai_stop_loop 或提示「等待审批中」} 。需核验 ai_chat_stop 对审批态的行为。
R4 generating 状态机复位散布 (已知技术债,见 memory devflow-generating-statemachine)generating 复位逻辑散布,远程 stop 若走到非常规路径可能不复位 卡在 generating=true 假死 桥接层 stop 走标准 ai_chat_stop command(复用现有复位逻辑),不开新路径。状态机加固是独立工作项,不在 Phase3 范围。
R5 AiSession 单例锁竞争 远程指令高频 + 桌面本地高频 → 抢 state Mutex 锁等待,延迟增高(非死锁) AiSession 用 tokio Mutex,临界区小。MVP 无忧,P4 高并发再优化。
R6 事件风暴压垮 miniapp 桌面多 conv 并行生成,miniapp 收到大量非当前 conv 事件 miniapp 端过滤(useAiChat.ts:109)但 WS 带宽与解析压力 tunnel 侧可选 conv 过滤(需反向状态同步,见 2.1,P4 做);MVP 全透传 + miniapp 过滤。
R7 WS 断连期间远程操作丢失 miniapp 发 send 时 WS 已断(uni-app send 静默失败) useAiChat.ts:386 已有发送失败回错,但用户可能误以为发出 miniapp wsStatus 已暴露,UI 应在 disconnected 时禁用发送按钮(前端职责)。
R8 桌面端离线,relay 丢弃指令 relay route 无对端在线时丢弃(relay.rs:135-143 delivered=0 返回 Ok) miniapp 发的命令静默消失 miniapp 侧应先查 device 在线状态(relay 可加 presence 查询,P4);MVP 提示「桌面端未连接」。

3.3 最高优先级风险:R1(同 conv 并发发送)

这是唯一可能造成状态损坏(非仅体验问题)的风险。需在实施前先核验 ai_chat_send 内部是否有 generating guard:

  • 若有(返回 Err 如「正在生成中」):桥接层把 Err 转成 AiError 事件 emit 回 miniapp,安全。
  • 若无:桥接层必须在调 ai_chat_send 前自行检查 conv_read(conv).generating,true 则拒绝。这是桥接层的硬性兜底,不可省

设计层面定:桥接层 send 路由 必须前置 generating 检查,无论 ai_chat_send 内部是否有 guard(防御性编程,双保险)。其他命令(stop/approve)无需此检查。


四、分阶段实施路线(每阶段可独立验证可回退)

按「协议统一 → 单向桥接 → 双向桥接 → 真机联调」四阶段递进,每阶段产出可独立验证、出问题可单独回退。

阶段 1:协议统一(tunnel 改纯透传)

目标:消除 1.1 的三重不匹配,tunnel 与 miniapp 在协议层握手成功。

改动:

  • df-tunnel:CommandHandler 签名 Fn(TunnelCommand)Fn(serde_json::Value)(events.rs/tunnel.rs);handle_inbound 直接把 payload Value 上抛回调,不再 from_value::<TunnelCommand>(tunnel.rs:407-454);TunnelEvent 保留但新增 send_raw_event(Value) 伴行。
  • df-miniapp:零改动(继续发 MiniCommand)。
  • src-tauri:本阶段不涉及。

验证:单元测试 —— tunnel 收到 relay 转发的 {kind:"command", payload:{cmd:"send_message", args:{...}}} 后,回调收到 Value 正是 payload 内容;parse_command_from_broadcast 改为返回 Value 而非 TunnelCommand。

回退:tunnel git revert 单 commit 即恢复强类型 TunnelCommand。miniapp 未动,无回退负担。

风险:低。纯类型放宽,不增业务逻辑。

阶段 2:单向桥接 — Event 上行透传(device→miniapp)

目标:桌面端 AI 生成事件能流到 miniapp,miniapp 能渲染流式回复。此阶段 Command 方向不通(miniapp 发的命令无人处理)。

改动:

  • src-tauri:接入 EventBus(把 55 处 app.emit("ai-chat-event", e) 改为 event_bus.publish(...) 一处;EventBus 内部 fan-out:app.emit + tunnel.send_raw_event(value))。tunnel 作为 subscriber 注册。
  • df-tunnel:零改动(阶段 1 已支持 send_raw_event)。
  • df-miniapp:零改动(useAiChat.ts:333 已按 msg.payload as AiChatEvent 处理)。

依赖:EventBus 当前是骨架(event_bus.rs:53 标 allow dead_code),需先做「批2 接入 emit 点」(其文档自述的下一步)。这是 EventBus 自身的里程碑,Phase3 阶段 2 与之合并。

验证:桌面端发一条 AI 消息,miniapp 收到流式 TextDelta 并渲染。端到端单向通路打通

回退:EVENT_BUS_ENABLED=false(event_bus.rs:42),EventBus 静默丢事件但原 app.emit 路径不受影响(event_bus.rs:45 兜底语义)。桌面前端不回归。tunnel subscriber 失败不影响 EventBus 对前端的 emit。

风险:

  • EventBus 接入 55 处 emit 是大改(纯机械替换但有遗漏风险)→ 用 grep 全量替换 + 编译校验 + 逐文件 review。
  • 若 EventBus 有 bug,publish 可能阻塞 emit 路径 → EventBus.publish 应是非阻塞 fan-out(订阅方 send 失败不阻断其他订阅)。

阶段 3:双向桥接 — Command 下行路由(miniapp→device)

目标:miniapp 发 send/stop/approve 等命令,桌面端执行,全双工闭环。

改动:

  • src-tauri:新增桥接模块,注册为 tunnel on_command 回调(收 Value → 解 MiniCommand → match 路由到 Tauri command)。持 AppHandle + State 句柄。含 R1 兜底:send 前置 generating 检查。
  • df-tunnel:零改动(阶段 1 已支持 Value 回调)。
  • df-miniapp:补齐命令发送(approve/authorize_dir/continue_loop/stop_loop 的 MiniCommand 构造,useAiChat.ts 当前只发 send/stop/switch/regenerate 4 条)。

验证:miniapp 发 send → 桌面触发 ai_chat_send → AI 回复事件经阶段 2 通路回流 miniapp 渲染。全双工闭环

回退:桥接模块独立,tunnel on_command 回调改为 no-op(空闭包)即切断下行,阶段 2 的上行通路不受影响。

风险:

  • R1(同 conv 并发)→ 阶段 3 必须实现 generating 前置检查。
  • R3(审批阻塞 + 远程指令)→ stop 命令对 AwaitingApproval 状态行为需先核验 ai_chat_stop。
  • 桥接层调 Tauri command 是直接 async fn 调用(非 IPC),需确保 AppHandle/State 句柄在桥接闭包内有效(从 tunnel 初始化处 clone,标准模式)。

阶段 4:真机联调 + 真多会话并发验证

目标:微信开发者工具 + 真机 + 桌面端三方联调,验证 F-09 多会话并发在跨端场景的鲁棒性。

验证矩阵:

场景 预期
miniapp 单会话收发 全双工正常
桌面 + miniapp 同 conv 同时发 一方被 R1 兜底拒绝,另一方成功
桌面 conv-X + miniapp conv-Y 并行 互不中断,事件按 conv_id 路由
miniapp 发 send 后桌面立即关 tunnel miniapp 收到 incomplete 事件或断连提示
miniapp 审批 + 桌面同时审批同 tool_call 一方成功一方收到「已处理」(R2)
WS 断线重连(uni-app onShow 触发) 事件不丢(重连后 AiConvStateChanged 同步状态)

回退:每阶段独立,阶段 4 出问题回退到阶段 3 的 mock 联调环境。

风险:

  • 真机 WS 稳定性(微信小程序 WS 限制:单连接、超时回收、后台冻结)→ 心跳间隔(25s,tunnel.rs:87)+ onShow 重连(ws.ts:82)已覆盖大部分。
  • R6 事件风暴(多 conv 并行)→ 真机若卡顿,阶段 4 后期考虑 tunnel 侧 conv 过滤。

五、总结:决策点与待办映射

设计决策点(本文给推荐,留主代理/用户定)

# 决策 推荐 备选 依据
D1 协议统一方案 A(tunnel 纯透传 + src-tauri 桥接解协议) B(miniapp 改发 TunnelCommand) §1.4 no-patch-groundwork + 契约源头唯一
D2 Event 透传子集 全 19 变体透传 裁剪高频子集 §2.1 方案 A 下 tunnel 零业务知识
D3 Event 注入点 EventBus 汇聚(subscriber 接入) 逐 emit 点 clone / 新建 gate 函数 §2.1 EventBus 即为此建
D4 switch_conversation 处理 (a) 不处理(MVP) (b) 新增 sync command §2.2 列入 P4
D5 TunnelCommand/TunnelEvent 强类型 保留(标 allow,作弱校验/测试) 删除 dead-code-reserve-keep 原则
D6 R1 兜底前置位置 桥接层 send 路由硬性加 依赖 ai_chat_send 内部 guard §3.3 双保险

待办登记(供主代理入 todo)

以下为实施项,本文只设计不实施。

  • F-260622-01-阶段1:tunnel CommandHandler 签名放宽为 Value + parse_command_from_broadcast 改返回 Value
  • F-260622-01-阶段2:EventBus 接入 55 处 emit 点(批2)+ tunnel 注册为 subscriber + send_raw_event
  • F-260622-01-阶段3:src-tauri 桥接模块(MiniCommand→Tauri command 路由 + R1 兜底)+ miniapp 补齐 approve/authorize_dir/continue/stop_loop 命令
  • F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵
  • 核验项:ai_chat_send 内部 generating guard 现状(决定 R1 兜底是否冗余)
  • 核验项:ai_chat_stop 对 AwaitingApproval 状态行为(R3)

附录 A:AiChatEvent 19 变体清单(核验自 mod.rs:104-216)

AiTextDelta / AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult / AiCompleted / AiError / AiAgentRound / AiHeartbeat / AiMaxRoundsReached / AiStreamRetry / AiDirAuthRequired / AiContextCleared / AiCompressing / AiCompressed / AiHelpRequired / AiConvStateChanged

(注:18 变体 + ConvState 派生,文档统称 18-19,F-260620-01 称「17 变体」是早期计数,以源码核验为准。)

miniapp handleEvent(useAiChat.ts:112-313)已覆盖其中 16 个,未覆盖:AiToolAutoApproved(仅 console)、AiDirAuthRequired(仅提示)、AiContextCleared/AiCompressing(no-op)—— 均属合理简化。

附录 B:src-tauri AI Chat 相关 Tauri command 清单(核验自 commands/chat.rs)

命令 关键参数
ai_chat_send message, language?, skill?, model_override?, conversation_id?, parts?, mention_spans?
ai_chat_force_send (同 send,强制绕过 generating 检查)
ai_chat_stop conversation_id?
ai_regenerate conversation_id, language?, model_override?
ai_chat_edit conversation_id, new_message, language?, model_override?
ai_approve tool_call_id, approved
ai_authorize_dir tool_call_id, decision("once"/"always"/"deny")
ai_continue_loop conversation_id
ai_stop_loop conversation_id
ai_is_generating conversation_id? → bool
ai_pending_tool_calls conv_id → Vec
ai_chat_clear / ai_chat_clear_context / ai_chat_compress_context (上下文管理类)

注:ai_chat_force_send 可作为 R1 兜底的备选(若桥接层确需在 generating 中强发,改调 force_send),但默认不用于远程(避免误并发)。

附录 C:核验证据索引(源码行号)

结论 证据
relay 透传不解析 crates/df-relay/src/broadcast.rs:58-59(payload: serde_json::Value)+ relay.rs:341(from_str 为 Value)
tunnel 收 TunnelCommand crates/df-tunnel/src/tunnel.rs:407-454(parse_command_from_broadcast) + events.rs:119-132
miniapp 发 MiniCommand apps/df-miniapp/src/types/relay.ts:88-93 + useAiChat.ts:379-385
AiChatEvent emit 散布 55 处 grep emit("ai-chat-event" 跨 7 文件 55 次
AiSession 真多会话 src-tauri/src/commands/ai/mod.rs:348-383(per_conv HashMap)+ :632-716(PerConvState)
审批挂起 + 恢复 src-tauri/src/commands/ai/agentic/mod.rs:1446(return 挂起)+ commands/chat.rs:521(try_continue 恢复)
EventBus 骨架未接 src-tauri/src/commands/ai/event_bus.rs:53(dead_code)+ :18(批2接入留注)
前端 listen 单事件名 src/api/ai.ts:258(listen('ai-chat-event'))