重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描
This commit is contained in:
@@ -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-10,IPC 层 *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
|
||||
|
||||
@@ -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 分析采样自动填基础信息
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user