//! 项目相关命令 use std::path::Path; use serde::{Deserialize, Serialize}; use tauri::State; use df_ai::provider::{ChatMessage, CompletionRequest}; use df_core::types::new_id; use df_project::scan::{collect_sample, detect_stack, extract_description, normalize_path}; use df_storage::models::ProjectRecord; use crate::state::AppState; use super::now_millis; /// 创建项目入参 #[derive(Debug, Deserialize)] pub struct CreateProjectInput { pub name: String, #[serde(default)] pub description: String, pub idea_id: Option, /// 绑定的本地代码目录(可选,空=不绑定) #[serde(default)] pub path: Option, /// 技术栈 JSON 数组字符串(可选,空则自动探测) #[serde(default)] pub stack: Option, } /// 列出未删除项目(过滤回收站) #[tauri::command] pub async fn list_projects(state: State<'_, AppState>) -> Result, String> { state.projects.list_active().await.map_err(|e| e.to_string()) } /// 创建项目,返回完整记录 /// /// 绑定目录时(path 非空):校验目录存在 + 防重复绑定 + 自动探测技术栈(stack 为空时)。 #[tauri::command] pub async fn create_project( state: State<'_, AppState>, input: CreateProjectInput, ) -> Result { // 绑定目录:校验存在 + 防重复 + 自动探测技术栈 let (path, stack) = match input.path.as_deref().map(str::trim).filter(|p| !p.is_empty()) { Some(p) => { if !Path::new(p).is_dir() { return Err(format!("目录不存在: {p}")); } if let Some(conflict) = find_binding_conflict(&state, p, None).await? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); } // stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime) let stack_json = match input.stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(s) => s.to_string(), None => { let root = std::path::PathBuf::from(p); let detected = tokio::task::spawn_blocking(move || detect_stack(&root)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; serde_json::to_string(&detected).map_err(|e| e.to_string())? } }; (Some(p.to_string()), Some(stack_json)) } None => (None, None), }; let now = now_millis(); let record = ProjectRecord { id: new_id(), name: input.name, description: input.description, status: "planning".to_string(), idea_id: input.idea_id, path, stack, created_at: now.clone(), updated_at: now, }; state .projects .insert(record.clone()) .await .map_err(|e| e.to_string())?; Ok(record) } /// 导入历史项目入参 #[derive(Debug, Deserialize)] pub struct ImportProjectInput { /// 待导入的本地目录绝对路径(已存在) pub path: String, /// 项目名(可选,空=用目录名) #[serde(default)] pub name: Option, /// 描述(可选,空=自动读 README 首段) #[serde(default)] pub description: Option, /// 技术栈 JSON 数组字符串(可选,空=自动探测) #[serde(default)] pub stack: Option, } /// 导入历史项目 — 用户选已存在的本地目录,复用 scan 探测 + 绑定一步创建项目记录。 /// /// 与 `create_project` 的区别:import 直接给 path,创建实体 + 绑定目录 + 探测栈 + /// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。 /// /// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description /// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 拼记录 insert → 返回。 #[tauri::command] pub async fn import_project( state: State<'_, AppState>, input: ImportProjectInput, ) -> Result { let path = input.path.trim().to_string(); if path.is_empty() { return Err("导入路径不能为空".to_string()); } if !Path::new(&path).is_dir() { return Err(format!("目录不存在: {path}")); } // 防重复绑定(normalize_path 规范化比较,防正反斜杠/末尾斜杠绕过) if let Some(conflict) = find_binding_conflict(&state, &path, None).await? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); } // 探测栈 + 读 description(spawn_blocking 防 IO 阻塞 tokio runtime) let root = std::path::PathBuf::from(&path); let want_name = input.name.clone(); let want_desc = input.description.clone(); let want_stack = input.stack.clone(); let (name, description, stack_json) = tokio::task::spawn_blocking(move || -> Result<_, String> { // name: 入参优先,否则取目录名 let name = match want_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(n) => n.to_string(), None => root .file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) .ok_or_else(|| "无法从路径解析项目名".to_string())?, }; // description: 入参优先,否则读 README 首段 let description = match want_desc.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(d) => d.to_string(), None => extract_description(&root).unwrap_or_default(), }; // stack: 入参优先,否则自动探测 let stack_json = match want_stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(s) => s.to_string(), None => { let detected = detect_stack(&root).map_err(|e| e.to_string())?; serde_json::to_string(&detected).map_err(|e| e.to_string())? } }; Ok((name, description, stack_json)) }) .await .map_err(|e| e.to_string())??; let now = now_millis(); let record = ProjectRecord { id: new_id(), name, description, status: "planning".to_string(), idea_id: None, path: Some(path), stack: Some(stack_json), created_at: now.clone(), updated_at: now, }; state .projects .insert(record.clone()) .await .map_err(|e| e.to_string())?; Ok(record) } /// 按 ID 查询项目 #[tauri::command] pub async fn get_project( state: State<'_, AppState>, id: String, ) -> Result, String> { state .projects .get_by_id(&id) .await .map_err(|e| e.to_string()) } /// 更新项目单个字段(字段名走 df-storage 白名单校验) #[tauri::command] pub async fn update_project( state: State<'_, AppState>, id: String, field: String, value: String, ) -> Result { state .projects .update_field(&id, &field, &value) .await .map_err(|e| e.to_string()) } /// 删除项目(软删 → 回收站,可恢复) #[tauri::command] pub async fn delete_project(state: State<'_, AppState>, id: String) -> Result { state.projects.soft_delete(&id).await.map_err(|e| e.to_string()) } /// 列出回收站项目(deleted_at IS NOT NULL) #[tauri::command] pub async fn list_deleted_projects( state: State<'_, AppState>, ) -> Result, String> { state.projects.list_deleted().await.map_err(|e| e.to_string()) } /// 恢复项目(从回收站还原,清 deleted_at) #[tauri::command] pub async fn restore_project(state: State<'_, AppState>, id: String) -> Result { state.projects.restore(&id).await.map_err(|e| e.to_string()) } /// 彻底删除项目(级联物理删 branches/releases/tasks,不可恢复) #[tauri::command] pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result { state .projects .purge_with_descendants(&id) .await .map_err(|e| e.to_string()) } // ============================================================ // 项目目录绑定 — 探测 / 防重复 / 重定位 / 有效性检查 // ============================================================ /// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。 /// /// 委托 `ProjectRepo::find_path_conflict`(DRY R-PD-11:与 tool_registry::bind_dir_to_project /// 共用同一防重复绑定实现)。normalize_path 内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过。 async fn find_binding_conflict( state: &AppState, path: &str, exclude_id: Option<&str>, ) -> Result, String> { let norm = normalize_path(path); state .projects .find_path_conflict(&norm, exclude_id) .await .map_err(|e| e.to_string()) } /// 探测目录技术栈(前端选目录后实时预览) #[tauri::command] pub async fn scan_project_stack(path: String) -> Result, String> { let root = std::path::PathBuf::from(&path); tokio::task::spawn_blocking(move || detect_stack(&root).map_err(|e| e.to_string())) .await .map_err(|e| e.to_string())? } /// 检查目录是否已被其他项目绑定(防重复绑定)。返回占用项目(若有)。 /// exclude_id 用于编辑/重定位时排除自身。 #[tauri::command] pub async fn check_path_binding( state: State<'_, AppState>, path: String, exclude_id: Option, ) -> Result, String> { find_binding_conflict(&state, &path, exclude_id.as_deref()).await } /// 重定位项目目录(目录移动后重新指向)。校验存在 + 防重复 + 重探测 stack,返回最新记录。 #[tauri::command] pub async fn relocate_project_path( state: State<'_, AppState>, id: String, new_path: String, ) -> Result { if !Path::new(&new_path).is_dir() { return Err(format!("目录不存在: {new_path}")); } if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); } // 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime) let root = std::path::PathBuf::from(&new_path); let stack = tokio::task::spawn_blocking(move || detect_stack(&root)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; let stack_json = serde_json::to_string(&stack).map_err(|e| e.to_string())?; state .projects .update_field(&id, "path", &new_path) .await .map_err(|e| e.to_string())?; state .projects .update_field(&id, "stack", &stack_json) .await .map_err(|e| e.to_string())?; state .projects .get_by_id(&id) .await .map_err(|e| e.to_string())? .ok_or_else(|| "项目不存在".to_string()) } /// 检查目录是否存在(详情页「目录是否还在」用) #[tauri::command] pub async fn check_path_exists(path: String) -> Result { Ok(Path::new(&path).is_dir()) } // ============================================================ // AI 扫描项目 — LLM 分析采样自动填基础信息 // ============================================================ /// AI 扫描项目结果(预览用,用户确认后填入 ProjectRecord) #[derive(Debug, Serialize)] pub struct AiScanResult { /// LLM 产出的项目摘要(空=LLM 未得出,前端提示手填) pub description: String, /// 技术栈(规则探测 ∪ LLM 推断,去重小写) pub stack: Vec, /// 项目类型(web/api/cli/library/desktop/mobile/monorepo/other) pub project_type: Option, /// LLM 原始返回(降级时含错误信息,前端可展示) pub raw: Option, } /// AI 扫描项目目录,自动分析基础信息(description/stack/project_type) /// /// 规则探测(detect_stack)兜底 + LLM 分析采样产出摘要。LLM 失败/解析失败降级纯规则。 /// 需已配置默认 AI provider(无则报错提示去设置)。 #[tauri::command] pub async fn scan_project_with_ai( state: State<'_, AppState>, path: String, ) -> Result { let root = Path::new(&path); if !root.is_dir() { return Err(format!("目录不存在: {path}")); } // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime) let scan_root = std::path::PathBuf::from(&path); let (rule_stack, sample) = tokio::task::spawn_blocking(move || { let stack = detect_stack(&scan_root)?; let sample = collect_sample(&scan_root)?; Ok::<_, anyhow::Error>((stack, sample)) }) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; // 2. 取默认 provider(优先 is_default,否则首个) let providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?; let pc = providers .iter() .find(|p| p.is_default) .cloned() .or_else(|| providers.into_iter().next()) .ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?; // 3. 构造 provider + LLM 调用(非流式) // build_provider_for 含空 key 早失败:Err → 走纯规则降级(与下方 LLM 失败降级行为一致) let provider = match crate::commands::ai::secret::build_provider_for(&pc) { Ok(p) => p, Err(e) => { return Ok(AiScanResult { description: String::new(), stack: rule_stack, project_type: None, raw: Some(format!("LLM 调用失败: provider 密钥不可用: {e}")), }); } }; let request = CompletionRequest { model: pc.default_model.clone(), messages: build_scan_prompt(&sample, &rule_stack), temperature: Some(0.2), max_tokens: Some(400), stream: false, tools: None, tool_choice: None, }; // 4. 双层限流 + complete let _global_permit = state.llm_concurrency.acquire_global().await; let _per_conv_permit = state.llm_concurrency.acquire_per_conv().await; let llm_result = provider.complete(request).await; // 5. 解析 + 合并 stack(LLM 失败降级纯规则) match llm_result { Ok(resp) => { let raw = resp.text.clone(); match parse_scan_result(&resp.text) { Some(p) => { let mut stack = rule_stack; for s in p.stack { let s = s.trim().to_lowercase(); if !s.is_empty() && !stack.contains(&s) { stack.push(s); } } Ok(AiScanResult { description: p.description, stack, project_type: p.project_type, raw: Some(raw) }) } None => Ok(AiScanResult { description: String::new(), stack: rule_stack, project_type: None, raw: Some(raw) }), } } Err(e) => Ok(AiScanResult { description: String::new(), stack: rule_stack, project_type: None, raw: Some(format!("LLM 调用失败: {e}")), }), } } struct ParsedScan { description: String, stack: Vec, project_type: Option, } /// 拼 LLM 扫描 prompt(system + 采样信息) fn build_scan_prompt(sample: &df_project::scan::ProjectSample, rule_stack: &[String]) -> Vec { let system = "你是项目分析助手。根据给定的项目采样信息,分析并输出项目基础信息。\n\ 严格只输出一个 JSON 对象,不要任何解释、markdown 代码块或额外文字。格式:\n\ {\"description\":\"一句话中文项目摘要,描述项目做什么,30-60字\",\"stack\":[\"技术栈标签(小写英文,如 vue/rust/go)\"],\"project_type\":\"web|api|cli|library|desktop|mobile|monorepo|other\"}\n\ 规则:stack 用小写英文标签且去重;description 中文;project_type 从给定枚举选最接近的。若无足够信息,description 填空字符串。"; let rule = if rule_stack.is_empty() { "(无)".to_string() } else { rule_stack.join(", ") }; let tree = if sample.tree.is_empty() { "(无)".to_string() } else { sample.tree.join("\n") }; let readme = sample.readme.clone().unwrap_or_else(|| "(无)".to_string()); let manifests = if sample.manifests.is_empty() { "(无)".to_string() } else { sample.manifests.iter().map(|(n, c)| format!("### {n}\n{c}")).collect::>().join("\n\n") }; let user = format!( "## 已探测技术栈(规则)\n{rule}\n\n## 目录结构(2层)\n{tree}\n\n## README\n{readme}\n\n## 清单文件\n{manifests}" ); vec![ChatMessage::system(system), ChatMessage::user(user)] } /// 解析 LLM 返回的 JSON(容错:直接解析失败则提取首个 {...} 再解析) fn parse_scan_result(text: &str) -> Option { let extract = |v: &serde_json::Value| -> Option { let description = v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(); let stack = v .get("stack") .and_then(|x| x.as_array()) .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect()) .unwrap_or_default(); let project_type = v.get("project_type").and_then(|x| x.as_str()).map(String::from); Some(ParsedScan { description, stack, project_type }) }; if let Ok(v) = serde_json::from_str::(text) { return extract(&v); } // 提取首个 {...}(LLM 可能裹 markdown 代码块或前后文字) let start = text.find('{')?; let end = text.rfind('}')?; if end <= start { return None; } let v = serde_json::from_str::(&text[start..=end]).ok()?; extract(&v) }