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

> 创建:2026-06-22 | 状态:📐 设计草案(纯设计,未实施任何代码)
> 上级索引:[../INDEX.md](../INDEX.md) | 续篇:[F-260620-01-跨端AIChat-微信小程序-2026-06-20.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`:
```ts
interface MiniCommand { cmd: string; args: Record<string, unknown> }
// 实际发送:{ cmd: "send_message", args: { message, conversation_id } }
```

tunnel 端 `crates/df-tunnel/src/events.rs:119-132`:
```rust
#[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 桥接复杂度 | 中。桥接层是「`MiniCommand` → `Tauri 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_id`、`content↔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_id` ≠ `conversation_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_send`、`conv_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<PendingToolCallInfo> |
| `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')) |
