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 桥接复杂度 | 中。桥接层是「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
理由(按权重排序):
- no-patch-groundwork 元原则(最高权重):relay/tunnel 是传输层,纯透传是它的天职。当前 tunnel 强类型
TunnelCommand是 Phase2 实现「先跑通握手」时的便利设计,但它在架构上是业务知识下沉到传输层的技术债。Phase3 联调正是清这笔债的时机 —— 要么现在清,要么以后协议每次扩张都拖 tunnel 一起改,债越滚越大。 - 契约源头唯一:权威业务协议在 src-tauri(Tauri command 即事实标准),miniapp 已主动对齐(relay.ts:82)。方案 A 让 tunnel 也对齐这个源头(透传即对齐「 whatever 两端约定」),三方契约源头唯一。方案 B 会造出第二个源头(tunnel 的 TunnelCommand)。
- 改动面更小且更安全:方案 A 的 tunnel 改动是「弱化解析」(删类型比加类型安全,删错最多漏一条命令,加错会阻断全部),miniapp 零改动,src-tauri 新增纯加法。方案 B 要改 miniapp 三个文件,回归风险更高。
- 桥接层省不掉,方案 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.typediscriminator 分派。 - 事件携带
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。理由:
- EventBus 是专门为此场景建的基础设施(event_bus.rs 文档自述),当前是骨架「批2接入 emit 点」未做 —— Phase3 桥接正好是它第一批真实 subscriber。
- tunnel 作为 EventBus 的一个 subscriber(
EventBus::subscribe返回EventSubscriber),EventBuspublish时 fan-out 到所有 subscriber。tunnel 的 subscriber 收到事件后send_event(TunnelEvent::...)。 - 与「全局事件数据总线」专项设计文档(docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md)阶段 6「跨端透传 df-tunnel adapter」完全对齐 —— 不是新造,是落地该规划的子集。
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 透传 → miniapponEvent(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<()>>
桥接层回调内部:
- 尝试反序列化
MiniCommand{cmd, args}(失败 → log+忽略,可能 miniapp 发了未知格式)。 match cmd { ... }路由到对应 Tauri command 函数(直接 async 调用,因为桥接层在 src-tauri 内,有 app+state 句柄)。- 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')) |