//! 通用应用设置 KV IPC — 前端 localStorage 迁移目标 //! //! 4 个 command 读写 `app_settings` 表(key/value JSON 字符串),承载主题、面板折叠态、 //! 最近使用项等前端持久化偏好。返回统一 `Result`。 use std::collections::HashMap; use tauri::State; use crate::state::AppState; use super::err_str; /// 取单个 key 的值(JSON 字符串),不存在返回 None #[tauri::command] pub async fn settings_get( state: State<'_, AppState>, key: String, ) -> Result, String> { state.settings.get(&key).await.map_err(err_str) } /// 写 key/value(`INSERT OR REPLACE`),刷新 updated_at #[tauri::command] pub async fn settings_set( state: State<'_, AppState>, key: String, value: String, ) -> Result { state.settings.set(&key, &value).await.map_err(err_str) } /// 取全部 key/value(前端启动时一次性拉回恢复偏好) #[tauri::command] pub async fn settings_get_all( state: State<'_, AppState>, ) -> Result, String> { let rows = state.settings.get_all().await.map_err(err_str)?; Ok(rows.into_iter().collect()) } /// 删除单个 key #[tauri::command] pub async fn settings_delete( state: State<'_, AppState>, key: String, ) -> Result { state.settings.delete(&key).await.map_err(err_str) } /// 获取 DevFlow 数据目录路径(运行期确定,跨平台) #[tauri::command] pub async fn get_data_dir(state: State<'_, AppState>) -> Result { Ok(state.data_dir.to_string_lossy().to_string()) } /// 读取审批超时分钟数(默认 15 分钟,0=禁用超时) /// /// 前端 Settings 页启动时拉取,展示当前配置。 #[tauri::command] pub async fn ai_get_approval_timeout(state: State<'_, AppState>) -> Result { Ok(state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst)) } /// 设置审批超时分钟数并持久化(默认 15 分钟,0=禁用超时) /// /// 前端 Settings 页保存时调用。值范围 0-1440(0=禁用,最大 24 小时)。 /// 注意:此命令与前端 AdvancedSection.vue 的 df-approval-timeout KV 持久化路径不同, /// 前端走 appSettings.set 走 KV,这里作为后端热改入口。两者最终读写同一个 KV。 #[tauri::command] pub async fn ai_set_approval_timeout( state: State<'_, AppState>, minutes: u64, ) -> Result { // 范围校验(0-1440 分钟,即 0-24 小时) if minutes > 1440 { return Err("审批超时分钟数超出范围(最大 1440 = 24 小时)".to_string()); } // 持久化到 Settings KV(与前端同 key,值为毫秒) let ms = minutes * 60_000; state .settings .set( crate::state::APPROVAL_TIMEOUT_KEY, &ms.to_string(), ) .await .map_err(err_str)?; // 更新内存 state.approval_timeout_minutes.store(minutes, std::sync::atomic::Ordering::SeqCst); tracing::info!("[APPROVAL-TIMEOUT] 已更新: {} 分钟 ({} ms)", minutes, ms); Ok(minutes) } /// 设置脚本安全配置(白/黑名单)并注入到 ScriptNode 运行时。 /// /// 前端设置页「命令执行安全」保存时调用。 /// 持久化到 app_settings KV,同时通过 df_nodes::set_script_safety_config 注入内存, /// 即时生效(无需重启)。 #[tauri::command] pub async fn set_script_safety( state: State<'_, AppState>, whitelist: String, blacklist: String, ) -> Result<(), String> { // 持久化到 KV state.settings.set("df-script-whitelist", &whitelist).await.map_err(err_str)?; state.settings.set("df-script-blacklist", &blacklist).await.map_err(err_str)?; // 注入运行时(即时生效,不依赖进程重启) df_nodes::script_node::set_script_safety_config(&whitelist, &blacklist); tracing::info!( whitelist = %whitelist, blacklist = %blacklist, "[SCRIPT-SAFETY] 运行时配置已更新" ); Ok(()) } /// 读取脚本安全配置。 #[tauri::command] pub async fn get_script_safety( state: State<'_, AppState>, ) -> Result<(String, String), String> { let wl = state.settings.get("df-script-whitelist").await.map_err(err_str)?.unwrap_or_default(); let bl = state.settings.get("df-script-blacklist").await.map_err(err_str)?.unwrap_or_default(); Ok((wl, bl)) } /// 设置多 ReAct Plan 执行开关(默认关)。 /// 开启后 process_tool_calls 内以 JoinSet 并行执行同层工具。 #[tauri::command] pub async fn set_plan_execution(enabled: bool) -> Result<(), String> { df_ai::plan_executor::set_plan_execution(enabled); tracing::info!(enabled, "[PLAN-EXEC] IPC 开关已更新"); Ok(()) } /// 读取多 ReAct Plan 执行开关状态。 #[tauri::command] pub async fn get_plan_execution() -> Result { Ok(df_ai::plan_executor::plan_execution_enabled()) } /// 列出可用模板(内置 + 自定义)。 /// /// 内置模板从 assets/templates/ 加载;自定义模板从 app_settings KV(df-custom-templates)读取。 #[tauri::command] pub async fn list_templates(state: State<'_, AppState>) -> Result, String> { let mut templates = Vec::new(); // 内置模板 let builtin = [ ("code-review", "代码审查", "AI 驱动的代码审查流程"), ("bug-fix", "Bug 修复", "AI 驱动的 bug 修复流程"), ("feature-dev", "功能开发", "AI 驱动的功能开发全流程"), ]; for (id, name, desc) in &builtin { templates.push(TemplateInfo { id: id.to_string(), name: name.to_string(), description: desc.to_string(), builtin: true, yaml: String::new(), // 内置模板不返回全文(前端需要时单独加载) }); } // 自定义模板 if let Ok(Some(custom_json)) = state.settings.get("df-custom-templates").await { if let Ok(custom) = serde_json::from_str::>(&custom_json) { templates.extend(custom); } } Ok(templates) } /// 保存自定义模板(新建/覆盖)。 #[tauri::command] pub async fn save_template( state: State<'_, AppState>, template: TemplateInfo, ) -> Result<(), String> { let mut customs: Vec = state .settings .get("df-custom-templates") .await .map_err(err_str)? .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); // 按 id 去重(覆盖同名) customs.retain(|t| t.id != template.id); customs.push(template); let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?; state.settings.set("df-custom-templates", &json).await.map_err(err_str)?; Ok(()) } /// 删除自定义模板。 #[tauri::command] pub async fn delete_template(state: State<'_, AppState>, template_id: String) -> Result<(), String> { let mut customs: Vec = state .settings .get("df-custom-templates") .await .map_err(err_str)? .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let before = customs.len(); customs.retain(|t| t.id != template_id); if customs.len() < before { let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?; state.settings.set("df-custom-templates", &json).await.map_err(err_str)?; } Ok(()) } /// 模板信息(IPC 传输用) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TemplateInfo { pub id: String, pub name: String, pub description: String, /// true = 内置模板(只读);false = 自定义模板 pub builtin: bool, /// 自定义模板的 YAML 内容(内置模板为空) pub yaml: String, } /// 解决合并冲突(用户/reviewer 选择解决方案后调用)。 /// /// 更新 ai_conflicts 表的 resolution + resolved_by + resolved_at, /// 并 emit AiConflictResolved 事件供前端更新徽章。 #[tauri::command] pub async fn resolve_conflict( state: State<'_, AppState>, app_handle: tauri::AppHandle, conflict_id: String, plan_id: String, resolution: String, resolved_by: String, conversation_id: Option, ) -> Result<(), String> { use tauri::Emitter; let repo = df_storage::crud::ConflictRepo::new(&state.db); let now = df_types::now_millis().to_string(); let ok = repo.resolve(&conflict_id, &resolution, &resolved_by, &now) .await .map_err(err_str)?; if !ok { return Err(format!("冲突 {} 不存在或已解决", conflict_id)); } tracing::info!( conflict_id = %conflict_id, resolution = %resolution, resolved_by = %resolved_by, "[CONFLICT] 冲突已解决" ); // emit AiConflictResolved 事件 let ev = crate::commands::ai::AiChatEvent::AiConflictResolved { conflict_id, plan_id, resolution, resolved_by, conversation_id, }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = state.ai_event_bus.publish_event(ev); Ok(()) }