新增: Wave8 F-06 导入项目 + F-02 技能联想 + AR-6 Low 失败语义

- F-06 导入历史项目:scan.rs extract_description(README 首段 200 字) + import_project 命令(合并 create+bind,spawn_blocking 探测栈) + 前端 ProjectDetail 导入按钮 + store action;主代理补 lib.rs invoke_handler 注册(agent 未注册)
- F-02 技能联想使用:核对发现 sendMessage→ai_chat_send skill 调用链已完整(联想选中→带 skill→注入 SKILL.md),补 Esc 取消选中兜底
- AR-6 Low 失败语义统一(定向B):audit.rs Low 失败 emit AiToolCallCompleted(错误 result) 替代 AiError,消除前端 false/后端 loop 续紊乱,错误回填 tool_result 让 LLM 自处理
cargo/vue-tsc 0 err
This commit is contained in:
2026-06-14 22:13:51 +08:00
parent b195a38d64
commit f82dd8ba90
8 changed files with 248 additions and 5 deletions

View File

@@ -8,7 +8,7 @@ use tauri::State;
use df_ai::build_provider;
use df_ai::provider::{ChatMessage, CompletionRequest};
use df_core::types::new_id;
use df_project::scan::{collect_sample, detect_stack, normalize_path};
use df_project::scan::{collect_sample, detect_stack, extract_description, normalize_path};
use df_storage::models::ProjectRecord;
use crate::state::AppState;
@@ -90,6 +90,99 @@ pub async fn create_project(
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(