新增: 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:
@@ -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(定向B):Low 工具失败不 emit AiError。
|
||||
// 原逻辑 emit AiError 会令前端置 streaming=false + 错误气泡,但 process_tool_calls
|
||||
// 仍返回 pending_count=0,agentic loop 续下一轮 → 前端 false 后端跑,状态紊乱。
|
||||
// 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user