Files
DevFlow/src-tauri/src/commands/project.rs
T
lxy 5667da6cf4 优化: 边界加固(AI loop竞态根治+数据/审批/反馈/并发/错误分类)
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 绑定修
2026-08-05 22:10:32 +08:00

908 lines
37 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};
// 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)
}