新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
394
src-tauri/src/commands/ai/tool_registry.rs
Normal file
394
src-tauri/src/commands/ai/tool_registry.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
//! AI 工具注册表构建 + 文件路径校验
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
|
||||
|
||||
use df_core::types::new_id;
|
||||
|
||||
use crate::commands::now_millis;
|
||||
|
||||
/// 验证文件路径:禁止访问系统敏感目录
|
||||
fn validate_path(path: &str) -> anyhow::Result<()> {
|
||||
// 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃)
|
||||
let normalized = path.replace('/', "\\");
|
||||
let lower = normalized.to_lowercase();
|
||||
if lower.contains("..") {
|
||||
anyhow::bail!("禁止路径遍历 (..)");
|
||||
}
|
||||
if lower.contains("\\.ssh")
|
||||
|| lower.contains("\\.aws")
|
||||
|| lower.contains("\\.gnupg")
|
||||
|| lower.contains("\\appdata\\")
|
||||
|| lower.contains("\\programdata\\")
|
||||
|| lower.contains("\\windows\\")
|
||||
|| lower.contains("\\system32\\")
|
||||
{
|
||||
anyhow::bail!("禁止访问敏感系统目录");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
|
||||
///
|
||||
/// 双层校验:
|
||||
/// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界
|
||||
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
|
||||
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
|
||||
fn resolve_workspace_path(path: &str) -> anyhow::Result<PathBuf> {
|
||||
validate_path(path)?;
|
||||
let root = workspace_root();
|
||||
let resolved = if Path::new(path).is_absolute() {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
root.join(path)
|
||||
};
|
||||
// 词法层:防明显越界(不存在路径的兜底)
|
||||
if !resolved.starts_with(&root) {
|
||||
anyhow::bail!("禁止访问项目目录之外: {}", path);
|
||||
}
|
||||
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace
|
||||
if resolved.exists() {
|
||||
let canon_root = root.canonicalize()?;
|
||||
let canon_resolved = resolved.canonicalize()?;
|
||||
if !canon_resolved.starts_with(&canon_root) {
|
||||
anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path);
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// 构建 AI 工具注册表 — handler 即唯一执行路径(schema+risk+实现同源,消除双轨)
|
||||
///
|
||||
/// CRUD 工具闭包捕获 `db` Arc 重建 Repo;文件系统工具复用 resolve_workspace_path /
|
||||
/// list_dir_recursive。新增工具只改这里一处,定义与实现同源,编译期保证一致。
|
||||
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
let mut registry = AiToolRegistry::new();
|
||||
|
||||
// ── 只读 (Low) ──
|
||||
registry.register(
|
||||
"list_projects", "列出所有项目,返回项目列表(ID、名称、状态、描述)",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let mut items = repo.list_all().await?;
|
||||
items.truncate(50); // 防 LLM context 膨胀
|
||||
Ok(serde_json::to_value(items)?)
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_tasks", "列出任务,可按 project_id 筛选",
|
||||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
};
|
||||
tasks.truncate(50); // 防 LLM context 膨胀
|
||||
Ok(serde_json::to_value(tasks)?)
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_ideas", "列出所有想法",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||||
let mut items = repo.list_all().await?;
|
||||
items.truncate(50); // 防 LLM context 膨胀
|
||||
Ok(serde_json::to_value(items)?)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── 创建 (Medium) ──
|
||||
registry.register(
|
||||
"update_project", "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
|
||||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
|
||||
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
|
||||
match field { "name" | "status" | "description" | "path" | "stack" => {}, _ => anyhow::bail!("不允许更新字段 '{}'", field) }
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
repo.update_field(id, field, value).await?;
|
||||
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"create_project", "创建新项目",
|
||||
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("description", "string", false)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
|
||||
let description = args["description"].as_str().unwrap_or("");
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let record = ProjectRecord {
|
||||
id: new_id(), name: name.to_string(), description: description.to_string(),
|
||||
status: "planning".to_string(), idea_id: None,
|
||||
path: None, stack: None,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
|
||||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
|
||||
let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
|
||||
let dir = std::path::Path::new(path);
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!("目录不存在: {path}");
|
||||
}
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
// 防重复:canonicalize 规范化比较,防路径写法差异绕过
|
||||
let normalize = |s: &str| -> String {
|
||||
std::path::Path::new(s)
|
||||
.canonicalize()
|
||||
.map(|a| a.to_string_lossy().replace('\\', "/").to_lowercase())
|
||||
.unwrap_or_else(|_| s.trim_end_matches(['\\', '/']).replace('\\', "/").to_lowercase())
|
||||
};
|
||||
let target = normalize(path);
|
||||
let projects = repo.list_active().await?;
|
||||
for proj in &projects {
|
||||
if proj.id != id {
|
||||
if let Some(pp) = &proj.path {
|
||||
if normalize(pp) == target {
|
||||
anyhow::bail!("目录已被项目「{}」绑定", proj.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 探测技术栈
|
||||
let stack = df_project::scan::detect_stack(dir)?;
|
||||
let stack_json = serde_json::to_string(&stack)?;
|
||||
repo.update_field(id, "path", path).await?;
|
||||
repo.update_field(id, "stack", &stack_json).await?;
|
||||
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"create_task", "在指定项目下创建新任务",
|
||||
df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
|
||||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let record = TaskRecord {
|
||||
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
|
||||
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"create_idea", "捕获一个新想法",
|
||||
df_ai::ai_tools::object_schema(vec![("title", "string", true), ("description", "string", false), ("tags", "string", false), ("source", "string", false)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||||
let record = IdeaRecord {
|
||||
id: new_id(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32,
|
||||
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
|
||||
source: args["source"].as_str().map(|s| s.to_string()),
|
||||
promoted_to: None, ai_analysis: None, scores: None,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── 高风险 (High) ──
|
||||
registry.register(
|
||||
"delete_project", "删除项目及其所有关联数据",
|
||||
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let deleted = repo.delete(id).await?;
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"run_workflow", "运行指定的工作流 DAG",
|
||||
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High,
|
||||
Box::new(|_args: serde_json::Value| Box::pin(async move {
|
||||
// run_workflow 需完整 DAG 执行,返回提示由前端触发
|
||||
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 文件系统 ──
|
||||
registry.register(
|
||||
"read_file", "读取文件内容,返回文本内容。支持 offset 和 limit 参数分页读取大文件",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let metadata = tokio::fs::metadata(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||||
if metadata.len() > 1_048_576 {
|
||||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||||
}
|
||||
let content = tokio::fs::read_to_string(path).await
|
||||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||||
let result = if let Some(offset) = args["offset"].as_u64() {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let skip = offset as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(200) as usize;
|
||||
lines.into_iter().skip(skip).take(limit).collect::<Vec<&str>>().join("\n")
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
let line_count = content.lines().count();
|
||||
Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "lines": line_count }))
|
||||
})),
|
||||
);
|
||||
registry.register(
|
||||
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
|
||||
let mut entries = Vec::new();
|
||||
let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?;
|
||||
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
|
||||
})),
|
||||
);
|
||||
registry.register(
|
||||
"write_file", "写入或创建文件,自动创建不存在的父目录",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||||
RiskLevel::Medium,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||||
}
|
||||
tokio::fs::write(path, content).await
|
||||
.map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?;
|
||||
Ok(serde_json::json!({ "path": path, "bytes_written": content.len() }))
|
||||
})),
|
||||
);
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||||
///
|
||||
/// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部
|
||||
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
|
||||
fn list_dir_recursive<'a>(
|
||||
path: &'a str,
|
||||
recursive: bool,
|
||||
depth: usize,
|
||||
max_depth: usize,
|
||||
max_entries: usize,
|
||||
skip_noise: bool,
|
||||
result: &'a mut Vec<serde_json::Value>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let mut dir = tokio::fs::read_dir(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
if result.len() >= max_entries {
|
||||
return Ok(true); // 达上限截断
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let metadata = entry.metadata().await?;
|
||||
let is_dir = metadata.is_dir();
|
||||
result.push(serde_json::json!({
|
||||
"name": name,
|
||||
"type": if is_dir { "directory" } else { "file" },
|
||||
"size": metadata.len(),
|
||||
"depth": depth,
|
||||
}));
|
||||
// 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等)
|
||||
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
|
||||
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
|
||||
if truncated {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
|
||||
fn is_noise_dir(name: &str) -> bool {
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
".git", "node_modules", "target", "dist", "build",
|
||||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||||
];
|
||||
NOISE_DIRS.contains(&name)
|
||||
}
|
||||
Reference in New Issue
Block a user