- 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 依赖同步
484 lines
18 KiB
Rust
484 lines
18 KiB
Rust
//! 项目相关命令
|
||
|
||
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)
|
||
}
|