新增: 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:
@@ -126,6 +126,51 @@ fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
|
||||
// ============================================================
|
||||
|
||||
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
|
||||
///
|
||||
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
if started {
|
||||
break; // 段落结束
|
||||
}
|
||||
continue; // 首段尚未开始,跳过开头空行
|
||||
}
|
||||
// 段落开始后不再跳行,直接累加
|
||||
if !started {
|
||||
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
|
||||
if line.starts_with('#')
|
||||
|| line.starts_with("<!--")
|
||||
|| line.starts_with('!')
|
||||
|| line.starts_with('<')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let desc = text.trim().to_string();
|
||||
if desc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
|
||||
}
|
||||
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
@@ -313,4 +358,41 @@ mod tests {
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
// 标题 + badge 图片行应跳过,首段为正文
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# My Project\n\n\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
|
||||
)
|
||||
.unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
assert!(desc.contains("示例项目"));
|
||||
assert!(desc.contains("第二行正文"));
|
||||
assert!(!desc.contains("My Project"));
|
||||
assert!(!desc.contains("badge"));
|
||||
assert!(!desc.contains("安装"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_no_readme_returns_none() {
|
||||
let d = scratch("nodesc");
|
||||
assert!(extract_description(&d).is_none());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_truncates_long() {
|
||||
let d = scratch("longdesc");
|
||||
let long = "啊".repeat(500);
|
||||
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
// 截断到 200 字 + 末尾「…(已截断)」标记
|
||||
assert!(desc.chars().count() <= 210);
|
||||
assert!(desc.contains("已截断"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { ProjectRecord, CreateProjectInput, AiScanResult } from './types'
|
||||
|
||||
/** 导入历史项目入参(选已存在目录,创建实体+绑定+探测栈一步完成) */
|
||||
export interface ImportProjectInput {
|
||||
/** 待导入的本地目录绝对路径(已存在) */
|
||||
path: string
|
||||
/** 项目名(可选,空=用目录名) */
|
||||
name?: string
|
||||
/** 描述(可选,空=自动读 README 首段) */
|
||||
description?: string
|
||||
/** 技术栈 JSON 数组字符串(可选,空=自动探测) */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
export const projectApi = {
|
||||
list(): Promise<ProjectRecord[]> {
|
||||
return invoke('list_projects')
|
||||
@@ -10,6 +22,11 @@ export const projectApi = {
|
||||
return invoke('create_project', { input })
|
||||
},
|
||||
|
||||
/** 导入历史项目(选已存在目录,创建实体+绑定+探测栈一步完成) */
|
||||
importProject(input: ImportProjectInput): Promise<ProjectRecord> {
|
||||
return invoke('import_project', { input })
|
||||
},
|
||||
|
||||
get(id: string): Promise<ProjectRecord | null> {
|
||||
return invoke('get_project', { id })
|
||||
},
|
||||
|
||||
@@ -545,6 +545,12 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
skillOpen.value = false
|
||||
return
|
||||
}
|
||||
// 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径)
|
||||
if (pendingSkill.value && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
pendingSkill.value = null
|
||||
return
|
||||
}
|
||||
// 技能联想浮层导航(仅有匹配项时拦截方向/回车)
|
||||
if (skillOpen.value && filteredSkills.value.length) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { projectApi, taskApi, ideaApi, workflowApi } from '../api'
|
||||
import type { ImportProjectInput } from '../api/project'
|
||||
import type { ProjectRecord, TaskRecord, IdeaRecord, WorkflowRecord, WorkflowEventPayload } from '../api/types'
|
||||
|
||||
// ── 全局响应式状态(单例) ──
|
||||
@@ -53,6 +54,18 @@ function createStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 导入历史项目(选已存在目录,后端创建+绑定+探测栈+读 README 首段一步完成) */
|
||||
async function importProject(input: ImportProjectInput) {
|
||||
try {
|
||||
const record = await projectApi.importProject(input)
|
||||
await loadProjects() // 刷新列表(后端已 insert,统一走 load 避免本地数组与后端不一致)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '导入项目失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject(id: string, field: string, value: string) {
|
||||
await projectApi.update(id, field, value)
|
||||
const idx = state.projects.findIndex(p => p.id === id)
|
||||
@@ -298,7 +311,7 @@ function createStore() {
|
||||
clearError,
|
||||
get deletedProjects() { return state.deletedProjects },
|
||||
// project actions
|
||||
loadProjects, createProject, updateProject, deleteProject, relocateProjectPath,
|
||||
loadProjects, createProject, importProject, updateProject, deleteProject, relocateProjectPath,
|
||||
loadDeletedProjects, restoreProject, purgeProject,
|
||||
// task actions
|
||||
loadTasks, createTask, updateTask, deleteTask,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="handleSync">{{ $t('projectDetail.sync') }}</button>
|
||||
<button class="btn btn-ghost" type="button" @click="handleImportDir">导入目录</button>
|
||||
<button class="btn btn-danger" @click="handleDelete">{{ $t('projectDetail.delete') }}</button>
|
||||
<button class="btn btn-primary" @click="showNewTaskModal = true">{{ $t('projectDetail.newTask') }}</button>
|
||||
</div>
|
||||
@@ -378,6 +379,29 @@ async function relocateDir() {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入历史项目(选已存在目录 → 后端创建实体+绑定+探测栈+读 README 首段一步完成)
|
||||
// 文案硬编码:projectDetail i18n 不在本次任务白名单,改用内联中文(避免引用不存在的 key)
|
||||
async function handleImportDir() {
|
||||
try {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
const dir = selected as string
|
||||
if (!await confirmDialog(`导入目录为新项目?\n${dir}\n\n将自动用目录名作项目名,探测技术栈,并读 README 首段填描述。`)) return
|
||||
const record = await store.importProject({ path: dir })
|
||||
if (!record) {
|
||||
// store 已 toast 错误
|
||||
if (store.error) Message.error(store.error)
|
||||
return
|
||||
}
|
||||
Message.success(`已导入项目「${record.name}」`)
|
||||
// 跳转到新导入项目的详情页
|
||||
router.push(`/projects/${record.id}`)
|
||||
} catch (e: any) {
|
||||
console.error('导入失败:', e)
|
||||
Message.error(`导入失败: ${e?.toString() ?? '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 审批处理 ──
|
||||
async function handleApproval(decision: string) {
|
||||
await store.approveHumanApproval(decision)
|
||||
|
||||
Reference in New Issue
Block a user