AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)
agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留
聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡
循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测
错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测
数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)
git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})
安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)
幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)
附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
1382 lines
65 KiB
Rust
1382 lines
65 KiB
Rust
//! 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 |
|
|
//! | `list_models` | (无,读活跃 provider+事件推送) | — |
|
|
//! | (未知 cmd) | (兜底臂:log+忽略) | — |
|
|
//!
|
|
//! ## R1 兜底(硬性,不可省,§3.3 + D6)
|
|
//!
|
|
//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 口径:
|
|
//! 批3 收口后读 conv_state.is_active() 单一 enum 路径,开关 + generating bool 已退役)。
|
|
//! 生成中(Generating/Compressed)则拒绝,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::<AppState>()`(Manager trait)
|
|
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
|
|
// 故 import 不含 Manager(避免 unused)。
|
|
use crate::commands::ai::{
|
|
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
|
|
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
|
|
ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind,
|
|
ConvSummary, ModelInfo, PendingApproval,
|
|
};
|
|
// list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。
|
|
// 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。
|
|
use crate::commands::ai::prompt::get_active_provider;
|
|
// 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)。
|
|
// ContentPart:ai_chat_force_send 的 parts 参数类型(多模态,远程透传同 ai_chat_send)。
|
|
use df_ai::provider::{ChatMessage, ContentPart};
|
|
|
|
// ============================================================
|
|
// MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93)
|
|
// ============================================================
|
|
|
|
/// 小程序 → 桌面端 Command 协议载体。
|
|
///
|
|
/// 对齐 `apps/df-miniapp/src/types/relay.ts:88-93` 的 `MiniCommand` interface:
|
|
/// ```ts
|
|
/// interface MiniCommand { cmd: string; args: Record<string, unknown> }
|
|
/// ```
|
|
/// `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 兜底)
|
|
/// `#[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<Self, serde_json::Error> {
|
|
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::<AppState>()` 构造(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;
|
|
}
|
|
|
|
// ── force_send:ai_chat_force_send(强制复位+发送,绕过 generating guard) ──
|
|
// 与 send_message 同语义但「强制」:内部原子复位目标 conv 旧生成态再占用。
|
|
// 故 R1 generating 检查在此禁用(否则与「强制」语义矛盾,用户无法挣脱卡死态)。
|
|
// 对齐桌面端 force_send IPC,补 miniapp 经 relay 调不到的路由缺口。
|
|
"force_send" => {
|
|
route_force_send(&app, &state, command.args).await;
|
|
}
|
|
|
|
// ── edit_message:ai_chat_edit(改末条 user 后重发) ──
|
|
// edit 内部自有 can_accept_request guard(生成中返 Err),桥接层不再加 R1
|
|
// (R1 仅 send_message 硬性双保险,edit/compress/clear 各自有内部 guard)。
|
|
"edit_message" => {
|
|
route_edit_message(&app, &state, command.args).await;
|
|
}
|
|
|
|
// ── compress_context:ai_chat_compress_context(LLM 压缩历史) ──
|
|
// 内部 is_compressing 防重入 guard,失败返 Err。对齐桌面端 IPC 路由缺口。
|
|
"compress_context" => {
|
|
route_compress_context(&app, &state, command.args).await;
|
|
}
|
|
|
|
// ── clear_context:ai_chat_clear_context(分段清历史) ──
|
|
// 内部按 PROTECT_COUNT 保留近条,空会话 noop + emit。对齐桌面端 IPC 路由缺口。
|
|
"clear_context" => {
|
|
route_clear_context(&app, &state, command.args).await;
|
|
}
|
|
|
|
// ── stop:ai_chat_stop(conversation_id?) ──
|
|
// 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431),
|
|
// 与其它命令的 (app, state, ...) 顺序不同,此处对齐其真实签名。
|
|
"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;
|
|
}
|
|
|
|
// ── list_models:跨端模型列表同步(device→miniapp 推活跃 provider 的 enabled 模型) ──
|
|
// 调 get_active_provider(prompt.rs)读活跃 provider → 过滤 model_configs.enabled == true
|
|
// → publish_event(AiModelList) 跨端透传,供 miniapp 模型选择器渲染。
|
|
// 对齐 route_list_skills 模式(非 app.emit,publish 才跨端透传)。
|
|
"list_models" => {
|
|
route_list_models(&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<AiConversationRecord>`
|
|
/// 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<ConvSummary> = 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::<i64>().ok(),
|
|
created_at: r.created_at.parse::<i64>().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<AiMessageRecord>`
|
|
/// 2. 映射 `Vec<ChatMessage>`(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<ChatMessage> = 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 列(老库未迁移)"
|
|
);
|
|
match serde_json::from_str::<Vec<ChatMessage>>(&rec.messages) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %e,
|
|
conv_id = %conversation_id,
|
|
"[remote_bridge] load_messages 老 messages JSON 解析失败,跳过(推送空列表)"
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
_ => 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<PendingApproval> = {
|
|
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<SkillInfo>`(已按 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<SkillInfo> = 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 });
|
|
}
|
|
|
|
// ============================================================
|
|
// 模型列表同步路由(device→miniapp 推活跃 provider 的 enabled 模型)
|
|
// ============================================================
|
|
|
|
/// `list_models` 路由 —— 读活跃 provider 的 enabled 模型列表,跨端推回 miniapp。
|
|
///
|
|
/// 流程:
|
|
/// 1. `get_active_provider(state)`(prompt.rs 已 pub(crate))读活跃 provider —— 优先级:
|
|
/// `session.active_provider_id` 指定 → `is_default` → 首个;无 → Err
|
|
/// 2. 取 `provider.model_configs` 中 `enabled == true` 的模型,映射 `ModelInfo { model_id, label }`
|
|
/// (label 为 Option<String>,用户自定义别名;缺失由前端回退 model_id)
|
|
/// 3. 读 `session.active_provider_id`(活跃 provider id,兜底 provider.id)+ `provider.default_model`
|
|
/// 4. `state.ai_event_bus.publish_event(AiModelList)` 跨端透传
|
|
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
|
|
///
|
|
/// 职责:miniapp 模型选择器渲染(列出可选模型 + 当前默认),对齐桌面端模型选择体验;
|
|
/// 选择结果经 `send_message` / `regenerate` 的 `model_override` 透传回 device(route 已透传)。
|
|
///
|
|
/// 错误兜底:get_active_provider 返 Err(未配置 provider / 活跃 provider 不存在)
|
|
/// → publish 空 Vec(对齐 route_list_skills 失败推空列表语义,miniapp 选择器渲染空态不崩溃)。
|
|
async fn route_list_models(state: &State<'_, AppState>) {
|
|
// 读活跃 provider(失败 → 空列表兜底)。
|
|
let provider = match get_active_provider(state).await {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "[remote_bridge] list_models 读活跃 provider 失败,推送空列表");
|
|
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiModelList {
|
|
provider_id: String::new(),
|
|
default_model: String::new(),
|
|
models: Vec::new(),
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
|
|
// 活跃 provider id:读 session(轻量 lock 取单值),兜底 provider.id ——
|
|
// session.active_provider_id 可能在 get_active_provider 读 provider 后清空,兜底保字段不空。
|
|
let provider_id = {
|
|
let session = state.ai_session.lock().await;
|
|
session
|
|
.active_provider_id
|
|
.clone()
|
|
.unwrap_or_else(|| provider.id.clone())
|
|
};
|
|
|
|
// 过滤 enabled 模型 → ModelInfo { model_id, label }(label 可选)。
|
|
let models: Vec<ModelInfo> = provider
|
|
.model_configs
|
|
.iter()
|
|
.filter(|m| m.enabled)
|
|
.map(|m| ModelInfo {
|
|
model_id: m.model_id.clone(),
|
|
label: m.label.clone(),
|
|
})
|
|
.collect();
|
|
|
|
tracing::info!(
|
|
provider_id = %provider_id,
|
|
default_model = %provider.default_model,
|
|
count = models.len(),
|
|
"[remote_bridge] list_models 推送模型列表(跨端)"
|
|
);
|
|
|
|
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiModelList {
|
|
provider_id,
|
|
default_model: provider.default_model,
|
|
models,
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// 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_with_activity(问题3:按最近活跃排序,
|
|
// 返回带 last_active_at 的 ProjectActivityRecord)。本路由仅透传项目清单给 miniapp
|
|
// 联想浮层(消费 ProjectRecord 字段),不消费 last_active_at,故剥出 .record 还原类型。
|
|
let projects: Vec<df_storage::models::ProjectRecord> = match list_projects(state.clone(), None).await {
|
|
Ok(ps) => ps.into_iter().map(|a| a.record).collect(),
|
|
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.is_active()`(含 Generating / Compressed 派生态,批3 收口后单一 enum 路径)
|
|
///
|
|
/// 生成中则拒绝:**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<String>,空字符串规整为 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");
|
|
// 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后发送,若仅传 conversation_id=null,
|
|
// ai_chat_send 会落到桌面端 active_conversation_id(旧会话)致串会话。miniapp 新建会话发送时
|
|
// 显式传 new_conversation=true → 此处强制生成新 conv id(不走 active 兜底)。
|
|
let new_conversation = args_get_bool(&args, "new_conversation").unwrap_or(false);
|
|
// 解析目标 conv_id(广播 AiUserMessage + R1 检查 + ai_chat_send 三处共用,保证一致):
|
|
// - new_conversation=true → 强制新会话(新 id,不立即落库,由 ai_chat_send 生成后 save upsert)
|
|
// conversation_id 有值 → 用之;None → active 兜底(可能 None,ai_chat_send 内部懒创建)。
|
|
// 提前 await(if/else 表达式值计算上下文不允许 .await)。
|
|
let fallback_active = get_active_conv_id(state).await;
|
|
let resolved_conv_id: Option<String> = if new_conversation {
|
|
Some(df_types::types::new_id())
|
|
} else if let Some(id) = conversation_id.clone().filter(|s| !s.is_empty()) {
|
|
Some(id)
|
|
} else {
|
|
fallback_active
|
|
};
|
|
|
|
// ── R1 前置 generating 检查(双轨口径对齐 ai_is_generating) ──
|
|
// 目标 conv:resolved(入参/new_conversation 新建/active),与 ai_is_generating 同口径。
|
|
// 空目标(无 conv_id 且无 active):无任何 conv 在跑,放行。
|
|
if let Some(reject_conv_id) = check_generating_reject(state, resolved_conv_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<String> 转 AiError emit 回 miniapp ——
|
|
// 无论 R1 命中(理论上不会到这)/ 内部 guard 命中 / Provider 配置错 / 参数错,
|
|
// 用户都能在 miniapp 看到 AiError 反馈(否则命令返 Err 静默,miniapp 不知失败)。
|
|
|
|
// F-260805-02 图片输入:miniapp 选图转 base64 构造 ContentPart Image 片段,经 parts 透传
|
|
// ai_chat_send(桌面端已支持多模态,FR-S1 核验)。args_get_parts 反序列化失败降级 None(不阻断纯文本)。
|
|
let parts = args_get_parts(&args, "parts");
|
|
|
|
// 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 跳过)。
|
|
// BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus)
|
|
// 桌面前端收不到 → 微信 user 消息桌面不可见。补 emit 双写(桌面收),保留 publish(微信自回灌去重 +
|
|
// tunnel 透传其他 miniapp 订阅者)。
|
|
let user_msg_ev = AiChatEvent::AiUserMessage {
|
|
message: message.clone(),
|
|
// 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active),
|
|
// 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。
|
|
conversation_id: resolved_conv_id.clone(),
|
|
};
|
|
let _ = app.emit("ai-chat-event", user_msg_ev.clone());
|
|
let _ = state.ai_event_bus.publish_event(user_msg_ev);
|
|
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:远程透传用户选择
|
|
resolved_conv_id, // conversation_id:resolve 后目标 conv(新会话强制新建 id)
|
|
parts, // parts:miniapp 图片输入透传(ContentPart Image base64,None 走纯文本)
|
|
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,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 桌面独占命令跨端补路由(force_send / edit_message / compress_context / clear_context)
|
|
// ============================================================
|
|
//
|
|
// 背景:此 4 命令桌面端 IPC 有(chat.rs ai_chat_force_send / ai_chat_edit /
|
|
// ai_chat_compress_context / ai_chat_clear_context),但 remote_bridge 路由表缺,
|
|
// miniapp 经 relay 调不到。本批仅补 4 路由,对齐 route_send_message 模式:
|
|
// - 直接 async 调对应 ai_chat_xxx(非 IPC invoke)
|
|
// - 错误处理对齐:Err<String> 转 AiError emit 回 miniapp(命令返 Err 静默则 miniapp 不知失败)
|
|
//
|
|
// R1 generating 检查的取舍:
|
|
// - force_send:**禁用 R1**(强制复位+发送是其核心语义,R1 拦截会剥夺「挣脱卡死态」能力)
|
|
// - edit / compress / clear:不加 R1(各自内部已有 guard —— can_accept_request /
|
|
// is_compressing / 空会话 noop;R1 仅 send_message 硬性双保险,不泛化)
|
|
|
|
/// `force_send` 路由 —— 强制复位目标 conv 旧生成态后发送(绕过 generating guard)。
|
|
///
|
|
/// 与 `route_send_message` 同参数面(message / conversation_id? / skill? / model_override?
|
|
/// / parts? / mention_spans?),区别:
|
|
/// - **禁用 R1 generating 检查** —— force_send 语义就是「无视当前生成态强制重发」,
|
|
/// ai_chat_force_send 内部原子「复位目标 conv 的 generating + 占用」(锁内瞬变无观察窗),
|
|
/// R1 拦截会让 force_send 永远打不出去(用户卡死时无法挣脱)。
|
|
/// - 仍广播 AiUserMessage —— force_send 会追加新 user 消息(同 send_message),
|
|
/// 桌面 useAiEvents 需据此补 user 气泡(否则只见孤立 assistant 响应)。
|
|
///
|
|
/// message 必填,缺失跳过。Err 转 AiError emit 回 miniapp(对齐 route_send_message)。
|
|
async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
|
|
let message = match args_get_string(&args, "message") {
|
|
Some(m) => m,
|
|
None => {
|
|
tracing::warn!("[remote_bridge] force_send 缺 message 参数,忽略");
|
|
return;
|
|
}
|
|
};
|
|
let conversation_id = args_get_string(&args, "conversation_id");
|
|
let model_override = args_get_string(&args, "model_override");
|
|
let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty());
|
|
let mention_spans = args_get_mention_spans(&args, "mention_spans");
|
|
// parts 多模态片段(对齐 ai_chat_force_send 第 6 参,远程透传同 ai_chat_send)。
|
|
let parts = args_get_parts(&args, "parts");
|
|
|
|
// 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息,
|
|
// 桌面端需据此补 user 气泡,防孤立 assistant 响应)。
|
|
// BUG-260805-02 双写:桌面端只 listen app.emit,补 emit 让桌面收到(对齐 route_send_message)。
|
|
let user_msg_ev = AiChatEvent::AiUserMessage {
|
|
message: message.clone(),
|
|
conversation_id: conversation_id.clone(),
|
|
};
|
|
let _ = app.emit("ai-chat-event", user_msg_ev.clone());
|
|
let _ = state.ai_event_bus.publish_event(user_msg_ev);
|
|
let result = ai_chat_force_send(
|
|
app.clone(),
|
|
state.clone(),
|
|
message,
|
|
None, // language:远程默认不传,内部 fallback "zh-CN"
|
|
skill, // skill:/ 联想选中技能名透传(技能正文注入)
|
|
model_override, // model_override:远程透传用户选择
|
|
parts, // parts:多模态片段透传(对齐 ai_chat_force_send)
|
|
mention_spans, // mention_spans:@ mention 区间透传
|
|
conversation_id.clone(),// conversation_id:强制复位+发送仅作用于目标 conv
|
|
)
|
|
.await;
|
|
if let Err(err_msg) = result {
|
|
tracing::warn!(
|
|
error = %err_msg,
|
|
conv_id = ?conversation_id,
|
|
"[remote_bridge] ai_chat_force_send 返回 Err,转 AiError emit 回 miniapp"
|
|
);
|
|
let _ = app.emit(
|
|
"ai-chat-event",
|
|
AiChatEvent::AiError {
|
|
error: err_msg,
|
|
error_type: Some(crate::commands::ai::ErrorType::Unknown),
|
|
conversation_id,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `edit_message` 路由 —— 改末条 active user 消息 content 后重发(truncate 其后)。
|
|
///
|
|
/// 参数:conversation_id(必填) + new_message(必填) + language? + model_override?。
|
|
/// 内部自有 can_accept_request guard(生成中返 Err),不加 R1。
|
|
///
|
|
/// 不广播 AiUserMessage —— edit 是替换末条 user(非新增),前端经 AiChatEvent 流自维护
|
|
/// (广播会致双气泡)。conversation_id / new_message 必填,缺失跳过。
|
|
async fn route_edit_message(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
|
|
let (conversation_id, new_message) = match (
|
|
args_get_string(&args, "conversation_id"),
|
|
args_get_string(&args, "new_message"),
|
|
) {
|
|
(Some(id), Some(msg)) => (id, msg),
|
|
_ => {
|
|
tracing::warn!(
|
|
"[remote_bridge] edit_message 缺 conversation_id 或 new_message 参数,忽略"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let language = args_get_string(&args, "language");
|
|
let model_override = args_get_string(&args, "model_override");
|
|
|
|
let result = ai_chat_edit(
|
|
app.clone(),
|
|
state.clone(),
|
|
conversation_id.clone(),
|
|
new_message,
|
|
language,
|
|
model_override,
|
|
)
|
|
.await;
|
|
if let Err(err_msg) = result {
|
|
tracing::warn!(
|
|
error = %err_msg,
|
|
conv_id = %conversation_id,
|
|
"[remote_bridge] ai_chat_edit 返回 Err,转 AiError emit 回 miniapp"
|
|
);
|
|
let _ = app.emit(
|
|
"ai-chat-event",
|
|
AiChatEvent::AiError {
|
|
error: err_msg,
|
|
error_type: Some(crate::commands::ai::ErrorType::Unknown),
|
|
conversation_id: Some(conversation_id),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `compress_context` 路由 —— LLM 压缩目标 conv 历史(保留近 PROTECT_COUNT 条)。
|
|
///
|
|
/// 参数:conversation_id(必填) + language?。内部 is_compressing 防重入 guard(返 Err),
|
|
/// LLM 失败则消息状态完全不变。不加 R1(compress 期间 loop 仍活跃但属预期)。
|
|
/// conversation_id 必填,缺失跳过。Err 转 AiError emit 回 miniapp。
|
|
async fn route_compress_context(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
|
|
let conversation_id = match args_get_string(&args, "conversation_id") {
|
|
Some(id) => id,
|
|
None => {
|
|
tracing::warn!("[remote_bridge] compress_context 缺 conversation_id 参数,忽略");
|
|
return;
|
|
}
|
|
};
|
|
let language = args_get_string(&args, "language");
|
|
|
|
let result =
|
|
ai_chat_compress_context(app.clone(), state.clone(), conversation_id.clone(), language).await;
|
|
if let Err(err_msg) = result {
|
|
tracing::warn!(
|
|
error = %err_msg,
|
|
conv_id = %conversation_id,
|
|
"[remote_bridge] ai_chat_compress_context 返回 Err,转 AiError emit 回 miniapp"
|
|
);
|
|
let _ = app.emit(
|
|
"ai-chat-event",
|
|
AiChatEvent::AiError {
|
|
error: err_msg,
|
|
error_type: Some(crate::commands::ai::ErrorType::Unknown),
|
|
conversation_id: Some(conversation_id),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `clear_context` 路由 —— 分段清目标 conv 历史(保留近 PROTECT_COUNT 条)。
|
|
///
|
|
/// 参数:conversation_id(必填)。空会话/全在保护区 noop + emit AiContextCleared。
|
|
/// 不加 R1(clear 是即时段落操作,无生成态依赖)。conversation_id 必填,缺失跳过。
|
|
/// Err 转 AiError emit 回 miniapp。
|
|
async fn route_clear_context(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
|
|
let conversation_id = match args_get_string(&args, "conversation_id") {
|
|
Some(id) => id,
|
|
None => {
|
|
tracing::warn!("[remote_bridge] clear_context 缺 conversation_id 参数,忽略");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let result = ai_chat_clear_context(app.clone(), state.clone(), conversation_id.clone()).await;
|
|
if let Err(err_msg) = result {
|
|
tracing::warn!(
|
|
error = %err_msg,
|
|
conv_id = %conversation_id,
|
|
"[remote_bridge] ai_chat_clear_context 返回 Err,转 AiError emit 回 miniapp"
|
|
);
|
|
let _ = app.emit(
|
|
"ai-chat-event",
|
|
AiChatEvent::AiError {
|
|
error: err_msg,
|
|
error_type: Some(crate::commands::ai::ErrorType::Unknown),
|
|
conversation_id: Some(conversation_id),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// R1 检查:目标 conv 是否正在生成。返 `Some(conv_id)` 表示拒绝(该 conv 在跑),
|
|
/// 返 `None` 表示放行(可发)。
|
|
///
|
|
/// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293):
|
|
/// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃;
|
|
/// 批3 收口后单一 enum 路径,开关 + generating bool 已退役)
|
|
///
|
|
/// 目标 conv 解析(对齐 ai_is_generating):
|
|
/// - 入参 conversation_id 非空优先
|
|
/// - 否则 fallback `active_conversation_id`
|
|
/// - 都无 → 返 None(放行,无 conv 在跑)
|
|
///
|
|
/// 读 ConvState 用无锁 `conv_states`(B-Phase2,零锁竞争)。
|
|
async fn check_generating_reject(
|
|
state: &State<'_, AppState>,
|
|
conversation_id: Option<&str>,
|
|
) -> Option<String> {
|
|
// 目标 conv:入参优先 → active 兜底(轻量 lock 取 active 单值)。
|
|
let target = {
|
|
let session = state.ai_session.lock().await;
|
|
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 在跑,放行
|
|
};
|
|
// B-Phase2:读侧切无锁 conv_states.is_active()(Generating/Compressed),对齐 ai_is_generating。
|
|
if state.conv_states.is_active(&target) {
|
|
Some(target)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 读当前 active conv_id(route_send_message resolve 目标 conv 用:conversation_id 缺失且
|
|
/// 非 new_conversation 时兜底 active)。与 check_generating_reject 内部 active 读取同口径
|
|
/// (轻量 lock 取单值,零额外持锁)。
|
|
async fn get_active_conv_id(state: &State<'_, AppState>) -> Option<String> {
|
|
state.ai_session.lock().await.active_conversation_id.clone()
|
|
}
|
|
|
|
// ============================================================
|
|
// args 字段提取 helper(从 serde_json::Value 稳健取值)
|
|
// ============================================================
|
|
|
|
/// 从 args 取 string 字段。非 string / 缺失 → None(调用方各自处理缺失语义)。
|
|
fn args_get_string(args: &Value, key: &str) -> Option<String> {
|
|
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<bool> {
|
|
args.get(key).and_then(|v| v.as_bool())
|
|
}
|
|
|
|
/// 从 args 取 mention_spans 字段(数组)反序列化为 `Vec<MentionSpanDto>`。
|
|
///
|
|
/// 用于 route_send_message 透传 miniapp @ mention 区间到 ai_chat_send。对齐
|
|
/// ai_chat_send 第 9 参 `Option<Vec<MentionSpanDto>>`(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<Vec<MentionSpanDto>> {
|
|
match args.get(key) {
|
|
Some(Value::Array(_)) => match serde_json::from_value::<Vec<MentionSpanDto>>(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,
|
|
}
|
|
}
|
|
|
|
/// 从 args 取 parts 字段(数组)反序列化为 `Vec<ContentPart>`(多模态片段)。
|
|
///
|
|
/// 用于 route_force_send 透传 miniapp 多模态片段到 ai_chat_force_send。对齐
|
|
/// ai_chat_force_send 第 6 参 `Option<Vec<ContentPart>>`(chat.rs:1539)。
|
|
///
|
|
/// 缺失 / 非数组 / 元素反序列化失败 → None(对齐 miniapp MVP 无 parts 走 None 的旧路径,
|
|
/// 不因 parts 脏数据阻断 force_send 路由)。失败仅 log warn,不 panic。语义同 args_get_mention_spans。
|
|
fn args_get_parts(args: &Value, key: &str) -> Option<Vec<ContentPart>> {
|
|
match args.get(key) {
|
|
Some(Value::Array(_)) => match serde_json::from_value::<Vec<ContentPart>>(args[key].clone())
|
|
{
|
|
Ok(parts) => {
|
|
if parts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(parts)
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"[remote_bridge] parts 反序列化失败,降级 None(不阻断 force_send)"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
// 缺失或非数组 → None(向后兼容无多模态的远程调用)。
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 单元测试
|
|
// ============================================================
|
|
//
|
|
// 覆盖:
|
|
// 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::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 路径测试 ──
|
|
//
|
|
// check_generating_reject 生产实现经 State<AppState> 读 conv_states(B-Phase2),
|
|
// 单元测试难构造 Tauri State。改测 R1 核心判定口径:用 ConvStateStore(无锁,独立可构造)
|
|
// 模拟生成态,复核 is_active() 拒绝/放行判定对齐 check_generating_reject / ai_is_generating。
|
|
|
|
/// R1 判定:无目标 conv 且无 active → 放行(返 None)。
|
|
#[test]
|
|
fn test_r1_no_target_pass() {
|
|
// 无入参 conv_id 且 active_conversation_id=None 在生产侧由 check_generating_reject 早 return。
|
|
// 此处只验空 store 的默认语义:任何 conv_id 读返 Idle 不活跃。
|
|
let store = crate::commands::ai::agentic::conv_state::ConvStateStore::new();
|
|
assert!(!store.is_active("any"), "空 store 读任意 conv_id 不活跃");
|
|
}
|
|
|
|
/// R1 判定:目标 conv 未在跑 → 放行(Idle,is_active()=false)。
|
|
#[test]
|
|
fn test_r1_target_idle_pass() {
|
|
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
|
let store = ConvStateStore::new();
|
|
store.transition("conv-idle", ConvState::Idle).unwrap();
|
|
let is_gen = store.is_active("conv-idle");
|
|
assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)");
|
|
}
|
|
|
|
/// R1 判定:目标 conv 在跑 → 拒绝(Generating,is_active()=true)。
|
|
#[test]
|
|
fn test_r1_target_generating_reject() {
|
|
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
|
let store = ConvStateStore::new();
|
|
// 生成态经 transition(Generating) 写入(单一真相源,无 generating bool 双轨)。
|
|
store.transition("conv-busy", ConvState::Generating).unwrap();
|
|
let is_gen = store.is_active("conv-busy");
|
|
assert!(is_gen, "conv-busy Generating 应拒绝");
|
|
}
|
|
|
|
/// R1 判定:conv_state 派生态(Compressed)is_active()=true(单一 enum 路径)。
|
|
///
|
|
/// 验证读 is_active() 判定活跃拒绝(enum 唯一真相源),含 Compressed 派生。
|
|
#[test]
|
|
fn test_r1_conv_state_active_reject() {
|
|
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
|
let store = ConvStateStore::new();
|
|
// Compressed 派生态:loop 仍活跃,is_active()=true。
|
|
store.transition("conv-state-busy", ConvState::Generating).unwrap();
|
|
store.transition("conv-state-busy", ConvState::Compressed).unwrap();
|
|
let is_gen = store.is_active("conv-state-busy");
|
|
assert!(
|
|
is_gen,
|
|
"conv_state=Compressed is_active() 应为 true(单一 enum 路径拒绝)"
|
|
);
|
|
}
|
|
|
|
/// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。
|
|
#[test]
|
|
fn test_r1_target_input_priority() {
|
|
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
|
let store = ConvStateStore::new();
|
|
// active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行
|
|
store.transition("conv-active", ConvState::Generating).unwrap();
|
|
// conv-other 不写入(默认 Idle)
|
|
let active_gen = store.is_active("conv-active");
|
|
let other_gen = store.is_active("conv-other");
|
|
assert!(active_gen, "conv-active 在跑");
|
|
assert!(!other_gen, "conv-other 未跑");
|
|
// 入参 conv-other 优先 → 判定 conv-other Idle → 放行
|
|
// (与 check_generating_reject 入参优先逻辑一致)
|
|
}
|
|
|
|
// ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ──
|
|
|
|
/// B-Phase3:PerConvState.conv_state 字段已迁 ConvStateStore。
|
|
#[test]
|
|
fn test_conv_state_store_default_idle() {
|
|
use crate::commands::ai::agentic::conv_state::ConvStateStore;
|
|
let store = ConvStateStore::new();
|
|
assert_eq!(store.get("nonexistent"), crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
|
"ConvStateStore 不存在的 conv_id 返 Idle");
|
|
}
|
|
}
|