项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目 文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。 后端接口: - 文件树查询:列工程目录,合并 Git 状态到每个文件 (噪音过滤 node_modules/target/.git 等,路径穿越防御) - 文件读取:支持文本/图片/二进制检测,1MB 上限 前端组件: - 文件树:递归树形展示,懒加载子目录,Git 状态标记 (橙色=已修改/绿色=已新增/灰色=未跟踪) - 文件预览:代码文本/图片/二进制三分支 - 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新 界面文案中英文同步补齐。
426 lines
23 KiB
Rust
426 lines
23 KiB
Rust
//! DevFlow Tauri 入口 — 初始化 AppState 并注册全部 IPC 命令
|
||
|
||
mod commands;
|
||
mod state;
|
||
|
||
use tauri::{Emitter, Listener, Manager};
|
||
|
||
use state::AppState;
|
||
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
|
||
use df_tunnel::TunnelClient;
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
|
||
let log_path = std::env::temp_dir().join("devflow-trace.log");
|
||
let log_file = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(&log_path)
|
||
.expect("创建日志文件失败");
|
||
let (non_blocking, _guard) = tracing_appender::non_blocking(log_file);
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
|
||
)
|
||
.with_writer(non_blocking)
|
||
.with_ansi(false)
|
||
.init();
|
||
tracing::info!(
|
||
path = %log_path.display(),
|
||
"[startup] 日志已初始化"
|
||
);
|
||
|
||
tauri::Builder::default()
|
||
.plugin(tauri_plugin_opener::init())
|
||
.plugin(tauri_plugin_dialog::init())
|
||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||
.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()))?;
|
||
// FR-S1:启动一次性迁移 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);
|
||
}
|
||
// B-260616-01: L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
|
||
let session_for_handshake = app_state.ai_session.clone();
|
||
app.manage(app_state);
|
||
|
||
// B-260616-01: 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;
|
||
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
|
||
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
|
||
// 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。
|
||
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原
|
||
// generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。
|
||
let dirty_convs: Vec<String> = session
|
||
.per_conv
|
||
.iter()
|
||
.filter(|(_, c)| c.conv_state.is_active())
|
||
.map(|(id, _)| id.clone())
|
||
.collect();
|
||
let was_generating = !dirty_convs.is_empty();
|
||
// 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
|
||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。
|
||
for cid in &dirty_convs {
|
||
if let Some(conv) = session.per_conv.get_mut(cid) {
|
||
match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) {
|
||
Ok(ns) => conv.conv_state = ns,
|
||
Err(e) => tracing::warn!(
|
||
conv_id = %cid,
|
||
error = %e,
|
||
"[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
||
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
|
||
.iter()
|
||
.filter_map(|cid| {
|
||
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
|
||
})
|
||
.collect();
|
||
// 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);
|
||
drop(session);
|
||
if was_generating {
|
||
// 补偿事件:每个残留 conv 各发一个 AiCompleted(按 conversation_id 路由),
|
||
// 前端 useAiEvents.ts:133-140 按 conv_id 各归各复位 streaming/generatingConvId。
|
||
for cid in &dirty_convs {
|
||
let _ = app_h.emit(
|
||
"ai-chat-event",
|
||
commands::ai::AiChatEvent::AiCompleted {
|
||
total_tokens: 0,
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
incomplete: None,
|
||
conversation_id: Some(cid.clone()),
|
||
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
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 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON):
|
||
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底,
|
||
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
|
||
let relay_url = state
|
||
.settings
|
||
.get("relay_url")
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
.filter(|s| !s.is_empty())
|
||
.map(|raw| {
|
||
serde_json::from_str::<String>(&raw)
|
||
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
|
||
})
|
||
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
|
||
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
|
||
let token = "devflow-relay-default-token".to_string();
|
||
|
||
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
|
||
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");
|
||
while let Ok(value) = rx.recv().await {
|
||
if let Err(e) = tunnel_for_sub.send_raw_event(value).await {
|
||
tracing::warn!("[tunnel-sub] send_raw_event 失败(可能未连接): {}", e);
|
||
}
|
||
}
|
||
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>,
|
||
>
|
||
},
|
||
);
|
||
|
||
// 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:断线自动重连(根治 device 离线) ──
|
||
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
|
||
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
|
||
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
|
||
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
|
||
let mut backoff = std::time::Duration::from_secs(2);
|
||
loop {
|
||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||
if !state.tunnel.is_connected() {
|
||
tracing::info!(
|
||
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
|
||
relay_url,
|
||
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);
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
error = %e,
|
||
backoff_secs = backoff.as_secs(),
|
||
"[tunnel-supervisor] 重连失败,退避后重试"
|
||
);
|
||
tokio::time::sleep(backoff).await;
|
||
// 指数退避上限 60s,防网络长故障时高频重连打 relay
|
||
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
Ok(())
|
||
})
|
||
.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::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::create_task,
|
||
commands::task::update_task,
|
||
commands::task::delete_task,
|
||
commands::task::restore_task,
|
||
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::get_module_git_status,
|
||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||
commands::module::get_module_file_tree,
|
||
commands::module::read_module_file,
|
||
// 灵感
|
||
commands::idea::list_ideas,
|
||
commands::idea::create_idea,
|
||
commands::idea::update_idea,
|
||
commands::idea::relate_ideas,
|
||
commands::idea::delete_idea,
|
||
commands::idea::restore_idea,
|
||
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,
|
||
// F-260616-03:达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45)
|
||
commands::ai::ai_continue_loop,
|
||
commands::ai::ai_stop_loop,
|
||
commands::ai::ai_pending_tool_calls,
|
||
commands::ai::ai_chat_clear,
|
||
commands::ai::ai_is_generating,
|
||
// 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,
|
||
// F-260614-04c: 负载均衡池可编辑层(enabled/weight 轻量更新 + caps 重建)
|
||
commands::ai::ai_update_provider_pool,
|
||
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
|
||
commands::ai::ai_fetch_models,
|
||
commands::ai::ai_probe_model,
|
||
// AI 对话管理
|
||
commands::ai::ai_conversation_create,
|
||
commands::ai::ai_conversation_list,
|
||
commands::ai::ai_conversation_switch,
|
||
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,
|
||
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,
|
||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
||
commands::ai::ai_authorize_dir,
|
||
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
||
commands::ai::audit::list_tool_executions,
|
||
// 知识库
|
||
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,
|
||
// 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改)
|
||
commands::settings::ai_get_approval_timeout,
|
||
commands::settings::ai_set_approval_timeout,
|
||
])
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
}
|