重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描

This commit is contained in:
2026-06-16 20:19:55 +08:00
parent d00b30f0ba
commit 2069f79198
19 changed files with 1518 additions and 311 deletions

View File

@@ -1,8 +1,11 @@
//! 灵感相关命令
use std::sync::Arc;
use serde::Deserialize;
use tauri::State;
use df_ai::provider::LlmProvider;
use df_core::types::{new_id, Priority};
use df_ideas::capture::Idea;
use df_storage::models::{IdeaRecord, ProjectRecord};
@@ -183,10 +186,13 @@ pub async fn evaluate_idea(
// 多维评分0-10IPC 层 *10 缩放为 0-100
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
// 对抗式评估
let eval = df_ideas::adversarial::AdversarialEngine::evaluate(&idea)
.await
.map_err(err_str)?;
// 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM无 provider/构造失败 → 启发式兜底)
let provider = build_default_provider(&state).await;
let engine = match provider {
Some(p) => df_ideas::adversarial::AdversarialEngine::new(Arc::from(p)),
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
};
let eval = engine.evaluate(&idea).await.map_err(err_str)?;
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
let positive_strength = eval.positive.confidence;
@@ -248,6 +254,31 @@ pub async fn evaluate_idea(
Ok(updated)
}
/// 从 DB 读取默认 provider 配置is_default 优先,否则首个)+ build_provider 构造实例。
///
/// 返回 `None` 的两种情况(统一走启发式评估兜底):
/// - DB 未配置任何 provider`list_all` 空或全无 is_default 且无首条)
/// - provider 密钥不可用keyring 无记录 / 纯空白),`build_provider_for` 返 Err
///
/// 复用 `commands::ai::secret::build_provider_for`resolve→ensure→build 三步),
/// 与 AI Chat / 项目扫描的 provider 构造路径统一FR-S1 密钥解析一致)。
async fn build_default_provider(state: &State<'_, AppState>) -> Option<Box<dyn LlmProvider>> {
let providers = state.ai_providers.list_all().await.ok()?;
let pc = providers
.iter()
.find(|p| p.is_default)
.cloned()
.or_else(|| providers.into_iter().next())?;
match crate::commands::ai::secret::build_provider_for(&pc) {
Ok(p) => Some(p),
Err(e) => {
// 密钥不可用:启发式兜底,不阻断评估(与 evaluate_idea LLM 失败降级语义一致)
tracing::warn!("默认 provider 密钥不可用,对抗评估走启发式: {e}");
None
}
}
}
/// IdeaRecord → df_ideas::Idea评估用status/time 不影响评分)
fn record_to_idea(record: &IdeaRecord) -> Idea {
let tags: Vec<String> = record

View File

@@ -7,7 +7,10 @@ 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_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description, is_monorepo,
normalize_path, DiscoveredProject,
};
use df_storage::models::ProjectRecord;
use crate::state::AppState;
@@ -42,18 +45,36 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectReco
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> {
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈
let (path, stack) = match input.path.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
let (path, stack) = match 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? {
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()) {
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(p);
@@ -72,20 +93,16 @@ pub async fn create_project(
let now = now_millis();
let record = ProjectRecord {
id: new_id(),
name: input.name,
description: input.description,
name,
description,
status: "planning".to_string(),
idea_id: input.idea_id,
idea_id,
path,
stack,
created_at: now.clone(),
updated_at: now,
};
state
.projects
.insert(record.clone())
.await
.map_err(err_str)?;
state.projects.insert(record.clone()).await.map_err(err_str)?;
Ok(record)
}
@@ -111,7 +128,7 @@ pub struct ImportProjectInput {
/// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。
///
/// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description
/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 拼记录 insert → 返回。
/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 走 create_with_binding insert → 返回。
#[tauri::command]
pub async fn import_project(
state: State<'_, AppState>,
@@ -124,18 +141,15 @@ pub async fn import_project(
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)
// 解析 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> {
// name: 入参优先,否则取目录名
let name = match want_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(n) => n.to_string(),
None => root
@@ -144,12 +158,10 @@ pub async fn import_project(
.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 => {
@@ -162,24 +174,7 @@ pub async fn import_project(
.await
.map_err(err_str)??;
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(err_str)?;
Ok(record)
create_with_binding(&state, name, description, None, Some(path), Some(stack_json)).await
}
/// 按 ID 查询项目
@@ -325,6 +320,228 @@ 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, 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。
#[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 间共享(零拷贝,引用计数)。
let futures: Vec<_> = items
.into_iter()
.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 results = futures::future::join_all(futures).await;
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)?;
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,
};
let _g = state.llm_concurrency.acquire_global().await;
let _c = state.llm_concurrency.acquire_per_conv().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 分析采样自动填基础信息
// ============================================================

View File

@@ -82,6 +82,8 @@ pub fn run() {
commands::project::relocate_project_path,
commands::project::check_path_exists,
commands::project::scan_project_with_ai,
commands::project::scan_directory_for_projects,
commands::project::import_projects_batch,
// 任务
commands::task::list_tasks,
commands::task::create_task,