Files
DevFlow/src-tauri/src/commands/project.rs
绝尘 2de0c6ecb7 重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复
- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
2026-06-15 05:14:42 +08:00

484 lines
18 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.
//! 项目相关命令
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<String>,
/// 绑定的本地代码目录(可选,空=不绑定)
#[serde(default)]
pub path: Option<String>,
/// 技术栈 JSON 数组字符串(可选,空则自动探测)
#[serde(default)]
pub stack: Option<String>,
}
/// 列出未删除项目(过滤回收站)
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectRecord>, 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<ProjectRecord, String> {
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈
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<String>,
/// 描述(可选,空=自动读 README 首段)
#[serde(default)]
pub description: Option<String>,
/// 技术栈 JSON 数组字符串(可选,空=自动探测)
#[serde(default)]
pub stack: Option<String>,
}
/// 导入历史项目 — 用户选已存在的本地目录,复用 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<ProjectRecord, String> {
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<Option<ProjectRecord>, 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<bool, String> {
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<bool, String> {
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<Vec<ProjectRecord>, 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<bool, String> {
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<bool, String> {
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<Option<ProjectRecord>, 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<Vec<String>, 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<String>,
) -> Result<Option<ProjectRecord>, 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<ProjectRecord, String> {
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<bool, String> {
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<String>,
/// 项目类型(web/api/cli/library/desktop/mobile/monorepo/other)
pub project_type: Option<String>,
/// LLM 原始返回(降级时含错误信息,前端可展示)
pub raw: Option<String>,
}
/// 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<AiScanResult, String> {
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<String>,
project_type: Option<String>,
}
/// 拼 LLM 扫描 prompt(system + 采样信息)
fn build_scan_prompt(sample: &df_project::scan::ProjectSample, rule_stack: &[String]) -> Vec<ChatMessage> {
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::<Vec<_>>().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<ParsedScan> {
let extract = |v: &serde_json::Value| -> Option<ParsedScan> {
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::<serde_json::Value>(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::<serde_json::Value>(&text[start..=end]).ok()?;
extract(&v)
}