新增: 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

@@ -309,11 +309,18 @@ pub(crate) async fn process_tool_calls(
(draft, Ok(val.to_string()))
}
Err(e) => {
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("工具 {} 执行失败: {}", draft.name, e),
// AR-6定向BLow 工具失败不 emit AiError。
// 原逻辑 emit AiError 会令前端置 streaming=false + 错误气泡,但 process_tool_calls
// 仍返回 pending_count=0agentic loop 续下一轮 → 前端 false 后端跑,状态紊乱。
// 现改为正常 emit AiToolCallCompletedresult=错误信息),错误包进 tool_result
// 让 LLM 看到工具失败自行决定下一步loop 正常续,前端状态一致。
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone),
});
(draft, Err(format!("错误: {}", e)))
(draft, Err(err_msg))
}
}
}

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(

View File

@@ -30,6 +30,7 @@ pub fn run() {
// 项目
commands::project::list_projects,
commands::project::create_project,
commands::project::import_project,
commands::project::get_project,
commands::project::update_project,
commands::project::delete_project,