优化: 边界加固(AI loop竞态根治+数据/审批/反馈/并发/错误分类)

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 绑定修
This commit is contained in:
lxy
2026-08-05 22:10:32 +08:00
parent ec9f0bf1ea
commit 5667da6cf4
16 changed files with 1696 additions and 215 deletions
+130 -11
View File
@@ -26,6 +26,7 @@
//! | `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)
@@ -65,8 +66,11 @@ 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, PendingApproval,
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};
@@ -97,7 +101,7 @@ pub struct MiniCommand {
/// Tauri command 名(如 "send_message" / "stop" / "approve" ...)
pub cmd: String,
/// 命令参数(原始 JSON 对象,缺失时回退空 Object 便于各臂 from_value 兜底)
/// BUG-260623-05:`#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object
/// `#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object
#[serde(default = "default_empty_object")]
pub args: Value,
}
@@ -367,6 +371,14 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
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 => {
@@ -601,6 +613,76 @@ async fn route_list_skills(state: &State<'_, AppState>) {
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 推项目/任务/灵感)
// ============================================================
@@ -699,11 +781,27 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
// 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:入参 conversation_id 优先 → active conv 兜底(与 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, conversation_id.as_deref()).await {
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!(
@@ -731,16 +829,27 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
// 无论 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 跳过)。
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage {
// 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(),
conversation_id: conversation_id.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(),
@@ -748,8 +857,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
None, // language:远程默认不传,ai_chat_send 内部 fallback "zh-CN"
skill, // skill:miniapp / 联想选中技能名透传(技能正文注入)
model_override, // model_override:远程透传用户选择
conversation_id.clone(),// conversation_id:远程透传目标 conv
None, // parts:多模态片段,远程 MVP 不传
resolved_conv_id, // conversation_id:resolve 后目标 conv(新会话强制新建 id)
parts, // parts:miniapp 图片输入透传(ContentPart Image base64,None 走纯文本)
mention_spans, // mention_spans:@ mention 区间透传(对齐桌面端 ai_chat_send)
)
.await;
@@ -815,10 +924,13 @@ async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Va
// 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息,
// 桌面端需据此补 user 气泡,防孤立 assistant 响应)。
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage {
// 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(),
@@ -1000,6 +1112,13 @@ async fn check_generating_reject(
}
}
/// 读当前 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 稳健取值)
// ============================================================