Files
DevFlow/src-tauri/src/lib.rs
T
lxy 795935bf6a 新增: 飞书文件消息入站与补充注入链完善
- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
2026-10-15 14:27:22 +08:00

675 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! DevFlow Tauri 入口 — 初始化 AppState 并注册全部 IPC 命令
mod commands;
mod state;
// 媒体存储域(#958 引用化):hash 去重落盘/OSS 双写/引用形态,飞书与桌面粘贴图共用
mod media_store;
// Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例
mod mcp;
// 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口
mod local_proxy_gateway;
mod tray;
mod feishu_channel;
// 双进程共用日志初始化(数据目录落点+启动期轮转+%TEMP% 旧日志迁移)
// main.rs 经 devflow_lib::logging 访问,须 pub
pub mod logging;
use tauri::{Emitter, Listener, Manager};
// UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt
use tauri_plugin_notification::NotificationExt;
use state::AppState;
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
use df_tunnel::TunnelClient;
/// 关窗进行中提示哨兵:tray-hide-notice 每进程只发一次(反复关窗不重复弹气泡)。
static CLOSE_NOTICE_SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 首次关窗托盘化说明的持久化标记文件名(app_data_dir 下;存在 = 历史上已提示过,不再弹)。
/// 用文件而非进程哨兵:解释一次即够,重启后不必再打扰;写入与通知成败解耦(先占坑语义),
/// 防通知通道异常(如无系统通知权限)导致每次关窗都尝试弹。
const TRAY_NOTICE_SENT_MARKER: &str = "tray-hide-notice-sent";
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 双进程共用日志初始化(logging.rs):落点系统应用数据目录 + 启动期 5MB 轮转 +
// %TEMP% 旧日志迁移。治「使用数据分析-2026-10-04」登记项(%TEMP% 系统清理无声删除+无轮转)。
// identifier 与 tauri.conf.json 一致(main.rs MCP 侧走编译期注入同源)。
let _guard = logging::init_logging("top.1216.devflow", "gui");
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
// P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默)
.plugin(tauri_plugin_notification::init())
// B2 应用内升级:tauri-plugin-updater(内建 ed25519 签名校验;端点/pubkey 见 tauri.conf.json plugins.updater)
.plugin(tauri_plugin_updater::Builder::new().build())
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
tray::show_main(app);
}))
.setup(|app| {
// 数据库放在系统应用数据目录 <app_data_dir> 下(dev/release 拆分):
// Dev 模式 devflow-dev.db(可随意改动/清空),Build 模式 devflow.db(长期保留真实运行数据)
let db_name = if cfg!(debug_assertions) { "devflow-dev.db" } else { "devflow.db" };
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join(db_name);
// 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
// 启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
if let Err(e) = tauri::async_runtime::block_on(
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
) {
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
}
// L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state);
// spawn_agent 子代理循环需要 AppHandle(emit/guard),工具注册期无法捕获,
// setup 阶段回注进程级 OnceLock(重复注入忽略,首次生效)。
crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone());
// D2a 工具结果外置根目录注入(app_data 下 tool-results/;未注入时直通降级)
commands::ai::tool_result_offload::init_base(data_dir.clone());
// P1 上下文侧工具结果外置(同根目录,tool-results/ 内以 `ctx-` 前缀区分)。
// 未注入时 extract_key_info 退化为纯摘要 = 改造前行为(零回归)。
df_ai::tool_offload::init_base(data_dir.clone());
// 飞书直连渠道:配置启用时起长连接(读 feishu-config;未配置/disabled 静默跳过)
feishu_channel::start_if_enabled(app.handle().clone());
// B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复。
// 常驻轮询循环,开关(df-unattended-enabled,默认关)关时每轮只读配置空转零副作用。
commands::ai::unattended::start(app.handle().clone());
// ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
// mcp_get_status IPC 经 State<Mutex<McpStatus>> 读取,spawn 内写锁同步。
let mcp_status = std::sync::Mutex::new(mcp::McpStatus {
running: false,
port: mcp::mcp_port(),
});
app.manage(mcp_status);
// 2) 启动 HTTP MCP server(复用 AppState.db,与 GUI 共享同一连接)。
// db 经 app.state::<AppState>() 取 Arc clone(manage 已注入,setup 后续可用)。
let db_for_mcp = app.state::<AppState>().db.clone();
mcp::spawn_mcp_http(app.handle(), db_for_mcp);
// 2.5) 启动本地工具代理统一网关(127.0.0.1:8899 → mysql/ssh/mongo/redis 4 代理端口)。
// 11 个 local_proxy 工具只连网关,端口收敛到网关映射表,失败非阻断(仅日志)。
crate::local_proxy_gateway::spawn_local_proxy_gateway(app.handle());
// 3) 创建托盘(关窗口不退出,托盘「退出」才真正结束进程)。失败非阻断。
if let Err(e) = tray::setup_tray(app.handle()) {
tracing::error!("[tray] 托盘创建失败(非阻断): {}", e);
}
// L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new(
std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(3600))
.unwrap_or_else(std::time::Instant::now),
));
let app_handle = app.handle().clone();
app.listen("ai-client-ready", move |_event| {
let session_arc = session_for_handshake.clone();
let app_h = app_handle.clone();
let last_at = last_handshake_at.clone();
tauri::async_runtime::spawn(async move {
// 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。
{
let mut last = last_at.lock().await;
let elapsed = last.elapsed();
if elapsed < std::time::Duration::from_secs(3) {
tracing::info!(
"[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)",
elapsed.as_millis()
);
return;
}
*last = std::time::Instant::now();
}
let mut session = session_arc.lock().await;
let app_state_ref = app_h.state::<AppState>();
// 职责分离(根治 BUG-2026-07-08 误杀活 loop):
// 生产环境前端 WebSocket 重连 / webview reload 会触发 ai-client-ready(非用户重发),
// 旧握手无脑强杀正在跑的 agentic loop(stop_flag + transition Idle + emit AiCompleted)
// → AI 中断不回复 → 用户「莫名其妙停止」(实证 cf5f44ed:seq26 后 loop 等下次 LLM 被杀)。
//
// 三道防线已就绪,握手无需也不应再强杀 loop:
// 1. guard.rs GeneratingGuard RAII:new→Generating,reset→Idle(正常),Drop→Idle(panic 兜底)。
// loop 自己复位 generating,握手强杀是多余且有害的旁路。
// 2. conv_state.rs can_accept_request 只 Idle|Error 可接 → generating 时 ai_chat_send
// 拒新消息 → 防「用户重发双 loop」(原 BUG-2026-07-08 的真实诉求)。
// 3. max_iterations 超时兜底 loop 卡死。
//
// 故握手职责收窄为「同步真实状态给前端」:读 conv_states 当前状态逐个 emit,
// 不改写 conv_states、不设 stop_flag、不收尾。loop 在跑则前端拿到 Generating,
// loop 已退出(guard 已复位)则拿到 Idle —— 前端状态与后端一致即可。
let dirty_convs: Vec<String> = app_state_ref.conv_states.active_convs();
let was_generating = !dirty_convs.is_empty();
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
// 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。
session.pending_approvals.retain(|_, a| !a.recovered);
// 握手需推状态的 conv 集合:dirty conv(后端已知活跃)+ 当前 active_conv(用户正看的)。
// dirty_convs 已含活跃 conv;active_conv 若非活跃(Idling/Eror)也补推一次让前端同步。
let active_conv = session.active_conversation_id.clone();
let mut convs_to_sync: Vec<String> = dirty_convs.clone();
if let Some(ref cid) = active_conv {
if !convs_to_sync.iter().any(|c| c == cid) {
convs_to_sync.push(cid.clone());
}
}
drop(session);
// 逐个 conv 读 conv_states 真实状态推给前端(loop 在跑推 Generating/Compressed,
// 已退出推 Idle)。ConvStateStore.get 同步无锁,不竞争 session lock。
for cid in &convs_to_sync {
let real_state = app_state_ref.conv_states.get(cid);
let _ = app_h.emit(
"ai-chat-event",
commands::ai::AiChatEvent::AiConvStateChanged {
conv_state: real_state,
conversation_id: Some(cid.clone()),
},
);
}
tracing::info!(
"[L0-handshake] 前端重连握手完成, was_generating={}, dirty_convs={:?}",
was_generating,
dirty_convs
);
});
});
// Phase3 跨端隧道集成:tunnel connect relay + subscriber 透传 ai_event_bus → miniapp
// + on_command 下行路由(miniap 指令 → remote_bridge → Tauri command)。
// spawn 后台 task:device_id/relay_url 解析(持久 KV,无则默认)→ subscriber 透传
// → on_command 回调注册 → connect(失败非阻断,可后续手动重连)。
let tunnel_app = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = tunnel_app.state::<AppState>();
// device_id:KV 取,无则生成 UUID v4 落库(持久,relay 按 device_id 路由配对)。
let device_id = match state.settings.get("device_id").await {
Ok(Some(id)) if !id.is_empty() => id,
_ => {
let id = uuid::Uuid::new_v4().to_string();
let _ = state.settings.set("device_id", &id).await;
id
}
};
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
// settings value 契约模糊(JSON 序列化带引号或裸值并存):经 get_string_norm
// 归一去引号(此处 from_str 写法已收敛为 state.rs SettingsNorm 正典,2026-10-01 DRY #2),
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
use state::SettingsNorm;
let relay_url = state
.settings
.get_string_norm("relay_url")
.await
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
// token:KV 取,无则回退默认常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)。
// 2026-10-05 配置化(原硬编码):relay 部署改 token 后桌面端无需重编译,
// 前端高级-连接面板(或 settings KV)写 relay_token 即生效。
let token = state
.settings
.get_string_norm("relay_token")
.await
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "devflow-relay-default-token".to_string());
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
// 治本:先查 is_connected()(AtomicBool 无锁读),未连接时静默丢弃事件,
// 避免每次 send_raw_event 都失败并记 WARN(旧实现用 suppress_until 降频,是治标)。
let mut rx = state.ai_event_bus.subscribe();
let tunnel_for_sub = state.tunnel.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("[tunnel-sub] subscriber task 启动,透传 ai_event_bus → relay");
let mut was_connected = false;
while let Ok(value) = rx.recv().await {
if !tunnel_for_sub.is_connected() {
if was_connected {
tracing::info!("[tunnel-sub] tunnel 已断开,暂停透传");
was_connected = false;
}
continue;
}
if !was_connected {
tracing::info!("[tunnel-sub] tunnel 已重连,恢复透传");
was_connected = true;
}
if let Err(e) = tunnel_for_sub.send_raw_event(value).await {
// 连接刚断(查询与发送间窗口),记一条 DEBUG 而非 WARN
tracing::debug!("[tunnel-sub] send_raw_event 失败(连接瞬断): {}", e);
was_connected = false;
}
}
tracing::info!("[tunnel-sub] subscriber task 退出(rx 关闭)");
});
// on_command 回调:miniapp 下行指令 → remote_bridge 路由 Tauri command。
// CommandHandler = Arc<dyn Fn(Value) -> BoxFuture<'static, ()> + Send + Sync>
// (tunnel.rs:45)。回调返 BoxFuture,Box::pin 构造 + 类型标注对齐签名。
//
// 借用处理:`handle_remote_command(payload, app, state)` 的 state 是
// `State<'_, AppState>`(newtype `&AppState`),借用 app。若先 `let state =
// app.state()` 再 move app 入函数,E0505(借用的 app 被 move)。
// 正解:同一表达式内 `app.clone()` move clone、`app.state()` 借用原 app,
// 原 app 不 move(仅被 state 借用到调用结束),借用检查通过。
let cmd_app = tunnel_app.clone();
let on_command: df_tunnel::tunnel::CommandHandler = std::sync::Arc::new(
move |payload: serde_json::Value| {
let app = cmd_app.clone();
Box::pin(async move {
crate::commands::ai::remote_bridge::handle_remote_command(
payload,
app.clone(),
app.state::<AppState>(),
)
.await;
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = ()> + Send>,
>
},
);
// H4 兜底审计 subscriber(无人值守可靠性专项 B2):订阅 ai_event_bus,
// Started{id,name,conversation_id}→入配对表 / Completed{id,result,conversation_id}→
// 经配对表取 name 落审计行。兜「事件到达但主链未落审计行」的旁路场景,
// 统一记 completed(Completed 无 name/isError,失败态由主链口径区分)。
{
let audit_db = state.db.clone();
let mut audit_rx = state.ai_event_bus.subscribe();
tauri::async_runtime::spawn(async move {
// call_id → (name, conversation_id) 配对表(容量上限防泄漏)
let mut pending: std::collections::HashMap<String, (String, Option<String>)> =
std::collections::HashMap::new();
while let Ok(event) = audit_rx.recv().await {
let Some(event_type) = event.get("type").and_then(|t| t.as_str()).map(|s| s.to_string()) else { continue };
let Some(call_id) = event.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()) else { continue };
match event_type.as_str() {
"AiToolCallStarted" => {
let name = event.get("name").and_then(|n| n.as_str()).unwrap_or("unknown").to_string();
let conv = event.get("conversation_id").and_then(|c| c.as_str()).map(|s| s.to_string());
if pending.len() > 512 {
pending.clear(); // 防泄漏:异常堆积时整体丢弃(审计兜底可容忍)
}
pending.insert(call_id, (name, conv));
}
"AiToolCallCompleted" => {
let Some((name, conv)) = pending.remove(&call_id) else { continue };
let Some(conv_id) = conv.as_deref() else { continue };
let repo = df_storage::crud::AiToolExecutionRepo::new(&audit_db);
let result_str = match event.get("result") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(other) => Some(other.to_string()),
None => None,
};
crate::commands::ai::audit::record_audit(
&repo,
conv_id,
&call_id,
&name,
"{}", // 事件面无原始 args(arguments 参数非 Option,空 JSON 占位)
"completed",
df_ai::ai_tools::RiskLevel::Low, // subscriber 侧无元数据,保守 Low(主链已记真实级别)
result_str,
Some("event_bus"),
None,
).await;
}
_ => {}
}
}
});
}
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => tracing::info!(
"[tunnel] 连接 relay 成功 device_id={} url={}",
device_id,
relay_url
),
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
}
// supervisor:轮询 is_connected,断开则重连
// 连接被拒(relay 未部署)→300s+INFO;其他错误→2s-60s WARN
let mut backoff = std::time::Duration::from_secs(2);
let mut last_was_refused = false;
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if !state.tunnel.is_connected() {
tracing::info!("[tunnel-supervisor] 断线重连 url={relay_url} device_id={device_id}");
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => {
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
backoff = std::time::Duration::from_secs(2);
last_was_refused = false;
}
Err(e) => {
let refused = is_connection_refused(&e);
if refused {
// relay 未运行:INFO + 300s 退避,不刷 WARN
if !last_was_refused {
tracing::info!("[tunnel-supervisor] relay 未运行({e}),300s 后轻量探测");
last_was_refused = true;
}
// 300s 退避(避免空转刷屏,relay 未运行时一直保持此间隔)
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
} else {
tracing::warn!("[tunnel-supervisor] 重连失败 {e},退避 {backoff_secs}s", backoff_secs = backoff.as_secs());
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
last_was_refused = false;
}
}
}
} else if last_was_refused {
last_was_refused = false;
}
}
});
Ok(())
})
// 辅助函数 is_connection_refused 在文件尾部
// 判断 TunnelError 是否为连接被拒(relay 未运行)。
// 匹配 WS connect_async 产生的 IO error:ECONNREFUSED(Win:10061,Unix:111)。
// 被拒 → relay 未部署,用长退避避免刷屏;其他错误(超时/DNS/网络瞬断)走指数退避。
.invoke_handler(tauri::generate_handler![
// 项目
commands::project::list_projects,
commands::project::create_project,
commands::project::import_project,
commands::project::get_project,
commands::project::update_project,
commands::project::delete_project,
commands::project::list_deleted_projects,
commands::project::archive_project,
commands::project::list_archived_projects,
commands::project::restore_project,
commands::project::purge_project,
commands::project::scan_project_stack,
commands::project::check_path_binding,
commands::project::relocate_project_path,
commands::project::check_path_exists,
commands::project::scan_project_with_ai,
commands::project::scan_directory_for_projects,
commands::project::import_projects_batch,
// 任务
commands::task::list_tasks,
commands::task::count_tasks,
commands::task::create_task,
commands::task::update_task,
commands::task::delete_task,
commands::task::restore_task,
// 归档功能 P1(V46/V47):任务归档 + 归档视图
commands::task::archive_task,
commands::task::list_archived_tasks,
commands::task::get_task_by_id,
commands::task::advance_task,
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
commands::task::create_task_link,
commands::task::remove_task_link,
commands::task::list_task_links,
commands::task::move_task_queue,
commands::task::get_task_tree,
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
commands::events::get_project_timeline,
// 知识图谱 Phase 3(对标设计 §五 + §2.3 + D10):项目基础设施配置 CRUD
commands::services::add_project_service,
commands::services::update_project_service,
commands::services::remove_project_service,
commands::services::list_project_services,
// 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询
commands::module::add_project_module,
commands::module::update_project_module,
commands::module::remove_project_module,
commands::module::list_project_modules,
commands::module::scan_project_modules,
commands::module::get_module_git_status,
commands::module::list_branches,
// 工程文件浏览(Batch 10):文件树 + 单文件预览
commands::module::get_module_file_tree,
commands::module::read_module_file,
commands::module::file_outline,
commands::module::get_module_file_meta,
commands::module::get_module_file_diff,
commands::module::get_module_commits,
commands::module::get_commit_detail,
commands::module::add_module_dependency,
commands::module::remove_module_dependency,
commands::module::list_module_dependencies,
commands::module::detect_module_cycles,
// 灵感
commands::idea::list_ideas,
commands::idea::create_idea,
commands::idea::update_idea,
commands::idea::relate_ideas,
commands::idea::delete_idea,
commands::idea::restore_idea,
// 归档功能 P1(V47):灵感归档 + 归档视图
commands::idea::archive_idea,
commands::idea::list_archived_ideas,
commands::idea::list_deleted_ideas,
commands::idea::evaluate_idea,
commands::idea::list_idea_evaluations,
commands::idea::promote_idea,
// 工作流
commands::workflow::run_workflow,
commands::workflow::list_workflow_executions,
commands::workflow::get_workflow_execution,
commands::workflow::approve_human_approval,
commands::workflow::cancel_workflow_node,
// AI 聊天
commands::ai::ai_chat_send,
commands::ai::ai_regenerate,
commands::ai::ai_chat_edit,
commands::ai::ai_chat_force_send,
commands::ai::ai_chat_stop,
commands::ai::ai_approve,
// 达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45)
commands::ai::ai_continue_loop,
commands::ai::ai_stop_loop,
// 流中断自动续跑探测(前端看门狗超时后调,按后端真实状态续跑/兜底收敛)
commands::ai::ai_resume_interrupted,
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
commands::ai::ai_inject_runtime,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context,
commands::ai::ai_list_providers,
commands::ai::ai_save_provider,
commands::ai::ai_set_provider,
commands::ai::ai_delete_provider,
// c: 负载均衡池可编辑层(enabled/weight 轻量更新 + caps 重建)
commands::ai::ai_update_provider_pool,
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
commands::ai::ai_fetch_models,
commands::ai::ai_probe_model,
// 用表单当前值一次性测试连接(不写库,新建/编辑未保存均可测)
commands::ai::ai_test_connection,
// AI 对话管理
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,
commands::ai::ai_list_recoverable,
commands::ai::ai_recover_parked,
// 挂机引擎(无人值守):状态查询 + 模式开关(设置页挂机区块/TopBar 徽章)
commands::ai::unattended::unattended_status,
commands::ai::unattended::unattended_set_enabled,
commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list,
commands::ai::context_briefs::ai_context_briefs,
commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned,
commands::ai::ai_update_conversation_goals,
// 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批
commands::ai::ai_task_boundary_get,
commands::ai::ai_task_boundary_set,
commands::ai::ai_conversation_export,
commands::ai::ai_list_skills,
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
commands::ai::ai_reload_skills,
commands::ai::ai_set_concurrency_config,
commands::ai::ai_set_agent_max_iterations,
commands::ai::ai_set_agent_max_retries,
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
commands::ai::ai_get_allowed_dirs,
commands::ai::ai_set_allowed_dirs,
// 工具风险元数据投影(前端启动构建 High 风险名单,审批二次确认门控)
commands::ai::ai_list_tools,
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
commands::ai::ai_authorize_dir,
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::record::list_tool_executions,
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
commands::ai::audit::record::list_task_conversations,
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
commands::ai::audit::record::tool_failure_stats,
// D2a 外置工具结果读回(审计面板遇句柄按 path 读原文全文)
commands::ai::audit::record::ai_read_offloaded_result,
// 知识库
commands::knowledge::knowledge_list,
commands::knowledge::knowledge_get,
commands::knowledge::knowledge_search,
commands::knowledge::knowledge_create,
commands::knowledge::knowledge_update_status,
commands::knowledge::knowledge_record_reuse,
commands::knowledge::knowledge_list_candidates,
commands::knowledge::knowledge_archive,
commands::knowledge::knowledge_retry_embedding,
commands::knowledge::knowledge_get_config,
commands::knowledge::knowledge_save_config,
commands::knowledge::knowledge_extract_now,
commands::knowledge::knowledge_get_detail,
commands::knowledge::knowledge_update,
commands::knowledge::knowledge_events,
// 通用应用设置 KV(前端 localStorage 迁移目标)
commands::settings::settings_get,
commands::settings::settings_set,
commands::settings::settings_get_all,
commands::settings::settings_delete,
commands::settings::get_data_dir,
// 应用版本号(编译期注入,关于页/更新检查消费)
commands::settings::app_get_version,
// B2 应用内升级:检查更新 + 自动检查偏好 KV
commands::update::app_check_update,
commands::update::app_update_get_prefs,
commands::update::app_update_set_prefs,
commands::update::app_update_mark_checked,
commands::update::app_update_mark_notified,
// B3 应用内升级:下载安装(进度事件 + 成功重启)
commands::update::app_download_install_update,
// 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改)
commands::settings::ai_get_approval_timeout,
commands::settings::ai_set_approval_timeout,
// ScriptNode 命令安全配置(白/黑名单,前端设置页写入)
commands::settings::set_script_safety,
// Plan 执行开关
commands::settings::set_plan_execution,
commands::settings::is_plan_execution_enabled,
// 冲突解决
commands::settings::resolve_conflict,
// 模板管理
commands::settings::list_templates,
commands::settings::save_template,
commands::settings::delete_template,
// CI 状态
commands::ci_status::get_commit_status,
// Phase3: 内嵌 HTTP MCP server 状态查询
crate::mcp::mcp_get_status,
])
// 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。
// 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
// P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次),
// 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。
// UX-P2 走查⑥:无会话关窗历史上零提示,用户误以为已退出。窗口随即隐藏,应用内气泡画了也
// 看不见,且前端气泡通道仅 running>0 才渲染,故走 OS 系统通知(托盘旁 toast)补位,
// 以持久化标记全生命周期只解释一次(解释性提示,与上方每进程一次的会话提醒互不替代)。
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" {
return; // 非主窗口放行真正销毁
}
api.prevent_close(); // 拦截真正关闭
let app = window.app_handle().clone();
let active = app.state::<AppState>().conv_states.active_convs().len();
if active == 0 {
// 首次(全历史一次)关窗托盘化:弹一次系统通知说明「已最小化到托盘」
match app.path().app_data_dir() {
Ok(dir) => {
let marker = dir.join(TRAY_NOTICE_SENT_MARKER);
if !marker.exists() {
let _ = app
.notification()
.builder()
.title("DevFlow")
.body("已最小化到系统托盘,程序仍在后台运行;托盘图标可随时唤起,托盘菜单「退出」才真正结束")
.show();
let _ = std::fs::write(&marker, b"1");
}
}
Err(e) => {
tracing::warn!("[tray] app_data_dir 解析失败,跳过首次关窗托盘提示: {}", e);
}
}
let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为)
return;
}
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡。
// 键名必须为 running:App.vue 监听端读 e.payload.running,曾发 "active" 致
// 端上恒取 undefined→按 0 处理,气泡整条链路静默失效(UX-P3 批次修正)。
if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "running": active }));
}
// 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染)
let win = window.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = win.hide();
});
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// 判断 TunnelError 是否为连接被拒(匹配 ECONNREFUSED 各平台表述)
fn is_connection_refused(e: &df_tunnel::TunnelError) -> bool {
let msg = e.to_string().to_lowercase();
msg.contains("refused")
|| msg.contains("10061")
|| msg.contains("积极拒绝")
|| msg.contains("actively refused")
}