- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写 - 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡) - 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡 - df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
675 lines
40 KiB
Rust
675 lines
40 KiB
Rust
//! 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")
|
||
}
|