AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)
agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留
聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡
循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测
错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测
数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)
git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})
安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)
幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)
附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
908 lines
37 KiB
Rust
908 lines
37 KiB
Rust
//! 项目相关命令
|
||
|
||
use std::path::Path;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use tauri::State;
|
||
|
||
use df_ai::provider::{ChatMessage, CompletionRequest};
|
||
// F-01 阶段5: 项目扫描描述路由 — TaskRequirements(采样含图时追加 Vision,
|
||
// 当前采样(ProjectSample)未含图像,故仅 Text)。池空/无匹配兜底 default_model。
|
||
// 注:路由已解耦(B-260618-03),原 Standard 智力约束已删除,纯 weight 选模型。
|
||
use df_ai::router::{
|
||
select_model_id, Modality, TaskRequirements,
|
||
};
|
||
use df_types::types::{new_id, ProjectStatus};
|
||
use df_project::scan::{
|
||
collect_sample, detect_stack, discover_projects, extract_description,
|
||
normalize_path, DiscoveredProject,
|
||
};
|
||
use df_storage::crud::{ProjectActivityRecord, ProjectQuery};
|
||
use df_storage::models::{ProjectEventRecord, ProjectRecord};
|
||
|
||
use crate::state::AppState;
|
||
|
||
use super::{err_str, now_millis};
|
||
|
||
// ============================================================
|
||
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
|
||
// ============================================================
|
||
|
||
/// 追加一条项目事件到 project_events(best-effort)。失败仅 tracing::warn 不阻断主 IPC。
|
||
///
|
||
/// `source` 标 `"human"`(IPC 层)。AI 工具路径在 tool_registry 内自行标 `"ai"`。
|
||
async fn emit_project_event(
|
||
state: &AppState,
|
||
project_id: &str,
|
||
event_type: &str,
|
||
entity_type: Option<&str>,
|
||
entity_id: Option<&str>,
|
||
) {
|
||
let record = ProjectEventRecord {
|
||
id: new_id(),
|
||
project_id: project_id.to_string(),
|
||
event_type: event_type.to_string(),
|
||
entity_type: entity_type.map(|s| s.to_string()),
|
||
entity_id: entity_id.map(|s| s.to_string()),
|
||
from_state: None,
|
||
to_state: Some("planning".to_string()),
|
||
context_json: None,
|
||
source: Some("human".to_string()),
|
||
conversation_id: None,
|
||
created_at: now_millis(),
|
||
};
|
||
if let Err(e) = state.project_events.insert(record).await {
|
||
tracing::warn!(
|
||
event_type = event_type,
|
||
project_id = project_id,
|
||
error = %e,
|
||
"[事件流] 埋点写入失败(不阻断主操作)"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 创建项目入参
|
||
#[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>,
|
||
}
|
||
|
||
/// 列出未删除项目(过滤回收站)。
|
||
///
|
||
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
|
||
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
|
||
///
|
||
/// 问题3(项目最近活跃排序):默认无 query 路径改走 list_active_with_activity,返回
|
||
/// 带 `last_active_at` 字段的 `ProjectActivityRecord`(COALESCE project_events 最新事件,
|
||
/// projects.updated_at 回退),按业务活跃排序而非元信息修改时间。query 路径(关键词/分页)
|
||
/// 同样补 last_active_at 字段(查 map 填充,无事件回退 updated_at),保持前端契约统一。
|
||
#[tauri::command]
|
||
pub async fn list_projects(
|
||
state: State<'_, AppState>,
|
||
query: Option<ProjectQuery>,
|
||
) -> Result<Vec<ProjectActivityRecord>, String> {
|
||
match query {
|
||
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active_with_activity,
|
||
// 按最近活跃排序(问题3)。list_active 分支语义已被 list_active_with_activity 覆盖
|
||
// (后者亦返 deleted_at IS NULL 的全部项目,仅多了 last_active_at 字段 + 排序键)。
|
||
Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
|
||
|| q.order_by.is_some()
|
||
|| q.limit.is_some()
|
||
|| q.offset.is_some() =>
|
||
{
|
||
// query 路径:list_by_query 返 ProjectRecord,补 last_active_at 字段。
|
||
// 单次拉全项目最新事件 map,逐条 COALESCE 填充,无事件回退 updated_at。
|
||
let records = state.projects.list_by_query(q).await.map_err(err_str)?;
|
||
let activity = state
|
||
.project_events
|
||
.latest_activity_per_project()
|
||
.await
|
||
.map_err(err_str)?;
|
||
let result = records
|
||
.into_iter()
|
||
.map(|r| {
|
||
let last_active_at = activity
|
||
.get(&r.id)
|
||
.cloned()
|
||
.unwrap_or_else(|| r.updated_at.clone());
|
||
ProjectActivityRecord {
|
||
record: r,
|
||
last_active_at,
|
||
}
|
||
})
|
||
.collect();
|
||
Ok(result)
|
||
}
|
||
_ => state.projects.list_active_with_activity().await.map_err(err_str),
|
||
}
|
||
}
|
||
|
||
/// 创建项目,返回完整记录
|
||
///
|
||
/// 绑定目录时(path 非空):校验目录存在 + 防重复绑定 + 自动探测技术栈(stack 为空时)。
|
||
#[tauri::command]
|
||
pub async fn create_project(
|
||
state: State<'_, AppState>,
|
||
input: CreateProjectInput,
|
||
) -> Result<ProjectRecord, String> {
|
||
create_with_binding(&state, input.name, input.description, input.idea_id, input.path, input.stack).await
|
||
}
|
||
|
||
/// 共用「校验 + 防重 + 探测 + insert」核心 — create_project 与 import_projects_batch 共用。
|
||
///
|
||
/// 对称收敛(决策记录:217 create/bind 去重):绑定逻辑单一实现,
|
||
/// 绑定目录时统一走「校验存在 + 防重复 + 自动探测 stack(stack 入参为空时)」。
|
||
/// relocate 不并入(走 update_field 非 insert)。
|
||
///
|
||
/// 返回 insert 后的完整记录。
|
||
async fn create_with_binding(
|
||
state: &AppState,
|
||
name: String,
|
||
description: String,
|
||
idea_id: Option<String>,
|
||
path: Option<String>,
|
||
stack: Option<String>,
|
||
) -> Result<ProjectRecord, String> {
|
||
// G5.2: name trim + 拒空前置到 insert 之前(防空名/纯空白入库)。
|
||
// create_project / import_project / import_projects_batch 均走本函数。
|
||
let name = name.trim().to_string();
|
||
if name.is_empty() {
|
||
return Err("项目名不能为空".to_string());
|
||
}
|
||
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈
|
||
let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
|
||
Some(p) => {
|
||
// 拒绝原始路径含 `..`(防穿越),存入前调用 normalize_path 规范化
|
||
if p.contains("..") {
|
||
return Err(format!("路径不得包含 '..': {}", p));
|
||
}
|
||
// G5.2: 目录不存在返 Err(不再静默 create_dir_all 假成功绑空目录)。
|
||
// 对齐 import_project 语义——手误路径应提示确认而非静默建空目录。
|
||
// 确需自动建目录时,由调用方先建目录再创建/绑定。
|
||
if !Path::new(p).is_dir() {
|
||
return Err(format!("目录不存在: {}", p));
|
||
}
|
||
let normalized = normalize_path(p);
|
||
if let Some(conflict) = find_binding_conflict(state, &normalized, None).await? {
|
||
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||
}
|
||
// stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime)
|
||
let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||
Some(s) => s.to_string(),
|
||
None => {
|
||
let root = std::path::PathBuf::from(&normalized);
|
||
let detected = tokio::task::spawn_blocking(move || detect_stack(&root))
|
||
.await
|
||
.map_err(err_str)?
|
||
.map_err(err_str)?;
|
||
serde_json::to_string(&detected).map_err(err_str)?
|
||
}
|
||
};
|
||
(Some(normalized), Some(stack_json))
|
||
}
|
||
None => (None, None),
|
||
};
|
||
|
||
let now = now_millis();
|
||
let record = ProjectRecord {
|
||
id: new_id(),
|
||
name,
|
||
description,
|
||
status: ProjectStatus::Planning,
|
||
idea_id,
|
||
path,
|
||
stack,
|
||
created_at: now.clone(),
|
||
updated_at: now,
|
||
};
|
||
state.projects.insert(record.clone()).await.map_err(err_str)?;
|
||
// 工程系统:创建项目时自动建一个工程(path = 绑定目录,stack = 探测结果)。
|
||
// 单仓库项目退化:项目下只有一个工程,用户无感工程概念。
|
||
// 多仓库场景用户后续可添加更多工程(add_project_module IPC)。
|
||
if let Some(ref module_path) = record.path {
|
||
let now_str = df_types::now_millis().to_string();
|
||
let module = df_storage::models::ProjectModuleRecord {
|
||
id: df_types::types::new_id(),
|
||
project_id: record.id.clone(),
|
||
name: record.name.clone(),
|
||
path: module_path.clone(),
|
||
git_url: None, // 探测 git remote 补充(失败为 None,不影响)
|
||
stack: record.stack.clone(), // 复用项目级技术栈探测结果
|
||
auto_detected: true, // 自动创建,重新探测时可覆盖
|
||
sort_order: 0,
|
||
created_at: now_str.clone(),
|
||
updated_at: now_str,
|
||
description: None,
|
||
status: Some("active".to_string()),
|
||
};
|
||
if let Err(e) = state.project_modules.insert(module).await {
|
||
tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e);
|
||
}
|
||
}
|
||
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
|
||
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
|
||
if record.path.is_some() {
|
||
state.reload_allowed_dirs().await;
|
||
}
|
||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):项目创建事件。best-effort 不阻断。
|
||
//
|
||
// 设计 §2.4 event_type 枚举未列 project_created(枚举非穷尽,Repo 不做白名单校验——
|
||
// 见 project_event_repo.rs 注释「埋点层是唯一生产者,字符串字面量自带约束」)。此处沿用
|
||
// task_created/idea_created 命名约定新增 project_created,语义自洽。entity_type=project。
|
||
//
|
||
// 注:灵感晋升(create_from_idea)在 promote_idea 单独记 idea_promoted 事件(更精确),
|
||
// 此处一律记 project_created(项目实体自身视角),两事件互补不冲突。
|
||
emit_project_event(
|
||
state,
|
||
&record.id,
|
||
"project_created",
|
||
Some("project"),
|
||
Some(&record.id),
|
||
)
|
||
.await;
|
||
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)→ 走 create_with_binding 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));
|
||
}
|
||
|
||
// 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
|
||
// spawn_blocking 防 IO 阻塞 tokio runtime。stack 解析后透传给 create_with_binding
|
||
// (不再重复探测,与原行为一致)。
|
||
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> {
|
||
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())?,
|
||
};
|
||
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(),
|
||
};
|
||
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(err_str)?;
|
||
serde_json::to_string(&detected).map_err(err_str)?
|
||
}
|
||
};
|
||
Ok((name, description, stack_json))
|
||
})
|
||
.await
|
||
.map_err(err_str)??;
|
||
|
||
create_with_binding(&state, name, description, None, Some(path), Some(stack_json)).await
|
||
}
|
||
|
||
/// 按 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(err_str)
|
||
}
|
||
|
||
/// 更新项目单个字段(字段名走 df-storage 白名单校验)
|
||
///
|
||
/// B-260801-01(P0-1):0 行(id 不存在 / 已软删)→ 返 Err 而非假成功。原实现无视
|
||
/// update_field 返回值恒返 Ok(true),前端误以为成功但库无变更(UI/DB 不一致)。
|
||
///
|
||
/// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏,
|
||
/// title 不在 IPC 返回值——IPC 调用点(projects store)不展示带返回值的 toast,
|
||
/// title 仅供 AI 工具卡片路径(ai/tools/project.rs),IPC 路径返 title 为冗余。
|
||
#[tauri::command]
|
||
pub async fn update_project(
|
||
state: State<'_, AppState>,
|
||
id: String,
|
||
field: String,
|
||
value: String,
|
||
) -> Result<bool, String> {
|
||
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
|
||
let updated = state
|
||
.projects
|
||
.update_field(&id, &field, &value)
|
||
.await
|
||
.map_err(err_str)?;
|
||
if !updated {
|
||
return Err(format!("项目 ID {id} 不存在或已删除"));
|
||
}
|
||
// F-260620: path 字段变更后立即 reload 白名单(新绑定/重绑目录即时生效,防 AI 误弹窗)
|
||
if field == "path" {
|
||
state.reload_allowed_dirs().await;
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// 删除项目(软删 → 回收站,可恢复)
|
||
#[tauri::command]
|
||
pub async fn delete_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||
state.projects.soft_delete(&id).await.map_err(err_str)
|
||
}
|
||
|
||
/// 列出回收站项目(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(err_str)
|
||
}
|
||
|
||
/// 恢复项目(从回收站还原,清 deleted_at)
|
||
#[tauri::command]
|
||
pub async fn restore_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||
state.projects.restore(&id).await.map_err(err_str)
|
||
}
|
||
|
||
/// 彻底删除项目(级联物理删 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(err_str)
|
||
}
|
||
|
||
// ============================================================
|
||
// 项目目录绑定 — 探测 / 防重复 / 重定位 / 有效性检查
|
||
// ============================================================
|
||
|
||
/// 查找已绑定该目录的项目(排除 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(err_str)
|
||
}
|
||
|
||
/// 探测目录技术栈(前端选目录后实时预览)
|
||
#[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(err_str))
|
||
.await
|
||
.map_err(err_str)?
|
||
}
|
||
|
||
/// 检查目录是否已被其他项目绑定(防重复绑定)。返回占用项目(若有)。
|
||
/// 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(err_str)?
|
||
.map_err(err_str)?;
|
||
let stack_json = serde_json::to_string(&stack).map_err(err_str)?;
|
||
state
|
||
.projects
|
||
.update_field(&id, "path", &new_path)
|
||
.await
|
||
.map_err(err_str)?;
|
||
// F-260620: 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
|
||
state.reload_allowed_dirs().await;
|
||
state
|
||
.projects
|
||
.update_field(&id, "stack", &stack_json)
|
||
.await
|
||
.map_err(err_str)?;
|
||
state
|
||
.projects
|
||
.get_by_id(&id)
|
||
.await
|
||
.map_err(err_str)?
|
||
.ok_or_else(|| "项目不存在".to_string())
|
||
}
|
||
|
||
/// 检查目录是否存在(详情页「目录是否还在」用)
|
||
#[tauri::command]
|
||
pub async fn check_path_exists(path: String) -> Result<bool, String> {
|
||
Ok(Path::new(&path).is_dir())
|
||
}
|
||
|
||
// ============================================================
|
||
// 批量扫描/导入历史项目 — F-260614-06(scan 第二步)
|
||
// ============================================================
|
||
|
||
/// 扫描发现的候选项目(规则发现,无 LLM)。前端预览表格只读展示。
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ScannedProjectItem {
|
||
pub path: String,
|
||
pub name: String,
|
||
pub stack: Vec<String>,
|
||
pub is_monorepo: bool,
|
||
/// 该目录是否已被某个项目绑定(防重复,前端标记禁选)
|
||
pub already_bound: bool,
|
||
}
|
||
|
||
/// 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。
|
||
///
|
||
/// 调 `discover_projects`(monorepo 一层展开 + detect_stack 非空过滤),
|
||
/// 标记每个候选是否已被项目绑定。前端用预览表格勾选后调 import_projects_batch。
|
||
#[tauri::command]
|
||
pub async fn scan_directory_for_projects(
|
||
state: State<'_, AppState>,
|
||
root_path: String,
|
||
) -> Result<Vec<ScannedProjectItem>, String> {
|
||
let root = Path::new(&root_path);
|
||
if !root.is_dir() {
|
||
return Err(format!("目录不存在: {}", root_path));
|
||
}
|
||
|
||
// 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
|
||
let scan_root = std::path::PathBuf::from(&root_path);
|
||
let discovered: Vec<DiscoveredProject> = tokio::task::spawn_blocking(move || {
|
||
discover_projects(&scan_root)
|
||
})
|
||
.await
|
||
.map_err(err_str)?
|
||
.map_err(err_str)?;
|
||
|
||
// 2. 标已绑定项(逐项 normalize_path 查重)
|
||
let mut out = Vec::with_capacity(discovered.len());
|
||
for d in discovered {
|
||
let already_bound = find_binding_conflict(&state, &d.path, None)
|
||
.await?
|
||
.is_some();
|
||
out.push(ScannedProjectItem {
|
||
path: d.path,
|
||
name: d.name,
|
||
stack: d.stack,
|
||
is_monorepo: d.is_monorepo,
|
||
already_bound,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// 批量导入历史项目单条结果
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ImportBatchItemResult {
|
||
/// 入参 path(回显,前端按 path 对齐结果)
|
||
pub path: String,
|
||
/// 成功:导入的项目名;失败:None
|
||
pub name: Option<String>,
|
||
/// 失败原因(成功为 None)
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
/// 批量导入历史项目结果(前端 toast 汇总)
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ImportBatchResult {
|
||
pub imported: usize,
|
||
pub skipped: usize,
|
||
pub items: Vec<ImportBatchItemResult>,
|
||
}
|
||
|
||
/// 单条批量导入入参
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct ImportBatchItemInput {
|
||
pub path: String,
|
||
#[serde(default)]
|
||
pub name: Option<String>,
|
||
}
|
||
|
||
/// 批量导入历史项目 — 对用户勾选项并发 LLM 抽 description + 入库绑定。
|
||
///
|
||
/// F-260614-06 决策⑤:扫描(scan_directory_for_projects)纯规则发现;此命令对勾选项
|
||
/// 并发跑 LLM(复用 scan_project_with_ai 的 complete 调用)抽 description。每项独立,
|
||
/// 非原子 —— 单项失败不影响其它项,逐项结果回传。LLM 全失败 description 留空(不喂噪音),
|
||
/// 用户可在详情页手填。
|
||
///
|
||
/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。
|
||
/// 默认 planning 状态(对齐 create_project),不关联 idea。
|
||
///
|
||
/// L16:并发上限分块(CHUNK_SIZE=4)——LLM 调用经双层 permit 限流,但 create_with_binding 内
|
||
/// 非 LLM IO(detect_stack/canonicalize/DB 查询/insert/reload_allowed_dirs 读全表)无全局限流,
|
||
/// 全量并发会产生调度/DB 锁竞争。分块串行处理 chunk、chunk 内并发,结果等价仅削峰。
|
||
#[tauri::command]
|
||
pub async fn import_projects_batch(
|
||
state: State<'_, AppState>,
|
||
items: Vec<ImportBatchItemInput>,
|
||
) -> Result<ImportBatchResult, String> {
|
||
if items.is_empty() {
|
||
return Ok(ImportBatchResult {
|
||
imported: 0,
|
||
skipped: 0,
|
||
items: Vec::new(),
|
||
});
|
||
}
|
||
|
||
// 取默认 provider(优先 is_default,否则首个)。无 provider 直接报错(批量无降级路径,
|
||
// 因为 description 是核心目的,无 LLM 与单 import_project 行为不同 —— 那走 import_project)
|
||
let providers = state.ai_providers.list_all().await.map_err(err_str)?;
|
||
let pc = providers
|
||
.iter()
|
||
.find(|p| p.is_default)
|
||
.cloned()
|
||
.or_else(|| providers.into_iter().next())
|
||
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?;
|
||
// build_provider_for 返回 Box<dyn LlmProvider>(非 Clone);多 future 共享需 Arc 包装。
|
||
// LlmProvider: Send + Sync + complete(&self) → Arc 共享安全。
|
||
let boxed = crate::commands::ai::secret::build_provider_for(&pc)
|
||
.map_err(|e| format!("provider 密钥不可用: {e}"))?;
|
||
let provider: std::sync::Arc<dyn df_ai::provider::LlmProvider> = std::sync::Arc::from(boxed);
|
||
|
||
// 每项独立 future,并发 join。失败逐项记录不影响其它。
|
||
// 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。
|
||
//
|
||
// L16:批量上限分块(chunk)。原 join_all 全量并发,虽 LLM 调用经 llm_concurrency 双层 permit
|
||
// 限流(global + per_conv),但 create_with_binding 内仍有重 IO(spawn_blocking detect_stack /
|
||
// normalize_path canonicalize / find_binding_conflict DB 查询 / insert project+module /
|
||
// reload_allowed_dirs 读全表 projects.path)。勾选数十项时全量并发会让这些非 LLM 操作同时
|
||
// 入队,产生 tokio 任务调度压力 + DB 锁竞争排队(reload_allowed_dirs 读全量 projects 表 × N)。
|
||
// 改分块串行处理各 chunk、chunk 内并发:结果与全量并发等价(每项独立无依赖,顺序不影响结果),
|
||
// 仅削平调度/DB 压力峰值。CHUNK_SIZE=4(对齐常见 4 核,与 LLM 限流槽位数同量级)。
|
||
const IMPORT_BATCH_CHUNK_SIZE: usize = 4;
|
||
let mut results: Vec<ImportBatchItemResult> = Vec::with_capacity(items.len());
|
||
// 分块:chunk 内并发 join,chunk 间串行 await,结果按原顺序聚合(与全量 join_all 等价顺序)。
|
||
for chunk in items.chunks(IMPORT_BATCH_CHUNK_SIZE) {
|
||
let chunk_futures: Vec<_> = chunk
|
||
.iter()
|
||
.cloned()
|
||
.map(|item| {
|
||
let state_ref = state.inner();
|
||
let provider = provider.clone();
|
||
let pc = pc.clone();
|
||
async move {
|
||
let path = item.path.trim().to_string();
|
||
if path.is_empty() {
|
||
return ImportBatchItemResult {
|
||
path,
|
||
name: None,
|
||
error: Some("路径为空".to_string()),
|
||
};
|
||
}
|
||
// 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
|
||
let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
// LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
|
||
tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
|
||
String::new()
|
||
}
|
||
};
|
||
let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||
match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
|
||
Ok(rec) => ImportBatchItemResult {
|
||
path,
|
||
name: Some(rec.name),
|
||
error: None,
|
||
},
|
||
Err(e) => ImportBatchItemResult {
|
||
path,
|
||
name: None,
|
||
error: Some(e),
|
||
},
|
||
}
|
||
}
|
||
})
|
||
.collect();
|
||
let chunk_results = futures::future::join_all(chunk_futures).await;
|
||
results.extend(chunk_results);
|
||
}
|
||
|
||
let imported = results.iter().filter(|r| r.name.is_some()).count();
|
||
let skipped = results.len() - imported;
|
||
Ok(ImportBatchResult {
|
||
imported,
|
||
skipped,
|
||
items: results,
|
||
})
|
||
}
|
||
|
||
/// 名字解析:入参优先,否则取目录名
|
||
fn resolve_name(path: &str, want: Option<String>) -> String {
|
||
if let Some(n) = want {
|
||
return n;
|
||
}
|
||
Path::new(path)
|
||
.file_name()
|
||
.and_then(|n| n.to_str())
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_else(|| path.to_string())
|
||
}
|
||
|
||
/// 复用 scan_project_with_ai 路径抽 description(轻量子代理)。
|
||
/// 双层 llm_concurrency permit 限流 + LLM 失败/解析失败返回空 description(不报错)。
|
||
async fn extract_description_via_llm(
|
||
state: &AppState,
|
||
provider: &std::sync::Arc<dyn df_ai::provider::LlmProvider>,
|
||
pc: &df_storage::models::AiProviderRecord,
|
||
path: &str,
|
||
) -> Result<String, String> {
|
||
let root = std::path::PathBuf::from(path);
|
||
let (rule_stack, sample) = tokio::task::spawn_blocking(move || {
|
||
let stack = detect_stack(&root)?;
|
||
let sample = collect_sample(&root)?;
|
||
Ok::<_, anyhow::Error>((stack, sample))
|
||
})
|
||
.await
|
||
.map_err(err_str)?
|
||
.map_err(err_str)?;
|
||
|
||
// F-01 阶段5: 项目扫描描述路由 — 无工具;采样未含图故仅 Text。
|
||
// select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。
|
||
let scan_req = TaskRequirements {
|
||
modalities: vec![Modality::Text],
|
||
needs_tool_use: false,
|
||
estimated_context: 0,
|
||
tier: None,
|
||
};
|
||
let scan_model = select_model_id(&scan_req, &pc.model_configs)
|
||
.unwrap_or_else(|| pc.default_model.clone());
|
||
let request = CompletionRequest {
|
||
model: scan_model,
|
||
messages: build_scan_prompt(&sample, &rule_stack),
|
||
temperature: Some(0.2),
|
||
max_tokens: Some(400),
|
||
stream: false,
|
||
tools: None,
|
||
tool_choice: None,
|
||
reasoning_content: None,
|
||
};
|
||
|
||
let _g = state.llm_concurrency.acquire_global().await;
|
||
// F-09 B 批5: 项目扫描无 conv_id(非会话内 LLM 调用),用合成 key 共享一槽(扫描低频,同类聚合限流)。
|
||
let _c = state.llm_concurrency.acquire_per_conv("__project_scan__").await;
|
||
let resp = provider.complete(request).await.map_err(err_str)?;
|
||
// 只取 description,其它字段丢弃(批量场景不需要 project_type/stack 细化)
|
||
let desc = parse_scan_result(&resp.text)
|
||
.map(|p| p.description)
|
||
.unwrap_or_default();
|
||
Ok(desc)
|
||
}
|
||
|
||
// ============================================================
|
||
// 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(err_str)?
|
||
.map_err(err_str)?;
|
||
|
||
// 2. 取默认 provider(优先 is_default,否则首个)
|
||
let providers = state.ai_providers.list_all().await.map_err(err_str)?;
|
||
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}")),
|
||
});
|
||
}
|
||
};
|
||
// F-01 阶段5: 项目扫描描述路由 — 无工具;采样未含图故仅 Text。
|
||
// select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。
|
||
let scan_req = TaskRequirements {
|
||
modalities: vec![Modality::Text],
|
||
needs_tool_use: false,
|
||
estimated_context: 0,
|
||
tier: None,
|
||
};
|
||
let scan_model = select_model_id(&scan_req, &pc.model_configs)
|
||
.unwrap_or_else(|| pc.default_model.clone());
|
||
let request = CompletionRequest {
|
||
model: scan_model,
|
||
messages: build_scan_prompt(&sample, &rule_stack),
|
||
temperature: Some(0.2),
|
||
max_tokens: Some(400),
|
||
stream: false,
|
||
tools: None,
|
||
tool_choice: None,
|
||
reasoning_content: None,
|
||
};
|
||
|
||
// 4. 双层限流 + complete
|
||
let _global_permit = state.llm_concurrency.acquire_global().await;
|
||
// F-09 B 批5: 项目扫描无 conv_id,用合成 key 共享一槽(扫描低频,同类聚合限流)。
|
||
let _per_conv_permit = state.llm_concurrency.acquire_per_conv("__project_scan__").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)
|
||
}
|