Files
DevFlow/src-tauri/src/commands/ai/tool_registry.rs
绝尘 672d677046 修复: 2196c77 workflow整文件替换回退恢复+scrollToBottom smooth+truncated标志
根因: 批次1 workflow(2196c77)做 @/ 路径统一时对 AiChat.vue/Ideas.vue
做了整文件替换(非手术式编辑),把 6254d06 之后积累的功能改动全部抹掉。

AiChat.vue 恢复 12 项功能:
- row-reverse 用户头像右侧布局
- groupedActive 对话按今天/昨天/更早分组
- archivedConvs 归档分组(可折叠)
- confirmClearChat 清空对话二次确认
- showBackToBottom 回到底部按钮(含 smooth 参数)
- queue UI 生成中消息排队可视化
- 技能联想(/ 触发浮层)
- token-usage 展示
- .ai-conv-group/item-actions/back-to-bottom/queue/skill 样式组
- onBeforeUnmount 补 stopListener() (CR-24 回归修复)

Ideas.vue 恢复 i18n/useConfirm/useRendered/过滤器/晋升逻辑

新增:
- F-260615-03 list工具 truncated 截断标志(tool_registry.rs)
- AR-8-scroll scrollToBottom 加 smooth 参数支持平滑滚动

vue-tsc 0 err / cargo check 0 err
2026-06-15 17:19:47 +08:00

948 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AI 工具注册表构建 + 文件路径校验
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
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(())
}
/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
fn truncate_output(s: &str, max: usize) -> (String, bool) {
if s.len() <= max {
return (s.to_string(), false);
}
let end = s.len();
let mut start = end.saturating_sub(max);
// 推进到 char 边界,避免从多字节 UTF-8 中间切开
while start < end && !s.is_char_boundary(start) {
start += 1;
}
let tail = &s[start..];
(
format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail),
true,
)
}
/// 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)
}
/// 为项目绑定代码目录的公共逻辑(FR-D7 抽取:create_project 与 bind_directory 共用,消除逐行重复)
///
/// 步骤:① 目录存在性校验 ② normalize_path 比较防重复绑定(跨其他项目)
/// ③ stack 由调用方提供则用,否则 spawn_blocking 探测(detect_stack 内含多次同步 fs IO)
/// ④ 写回 path / stack 字段
///
/// - `stack_opt`: 调用方解析好的逗号分隔技术栈原始串(None=让本函数自动探测)
/// - 返回最终采用的 stack(供调用方回填响应,保持原 bind_directory 返回结构)
async fn bind_dir_to_project(
repo: &df_storage::crud::ProjectRepo,
id: &str,
path: &str,
stack_opt: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let dir = std::path::Path::new(path);
if !dir.is_dir() {
anyhow::bail!("目录不存在: {path}");
}
// 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict
// 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。
let target = df_project::scan::normalize_path(path);
if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? {
anyhow::bail!("目录已被项目「{}」绑定", conflict.name);
}
// stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime)
let stack: Vec<String> = if let Some(s) = stack_opt {
s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
} else {
let dir_buf = std::path::PathBuf::from(path);
tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf))
.await
.map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))??
};
let stack_json = serde_json::to_string(&stack)?;
repo.update_field(id, "path", path).await?;
repo.update_field(id, "stack", &stack_json).await?;
Ok(stack)
}
/// 构建 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、名称、状态、描述。最多返回 50 条",
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_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let total = items.len();
let truncated = total > 50;
items.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
})
})},
);
registry.register(
"list_tasks", "列出任务,可按 project_id 筛选。最多返回 50 条",
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?
};
let total = tasks.len();
let truncated = total > 50;
tasks.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
})
})},
);
registry.register(
"list_ideas", "列出所有灵感。最多返回 50 条",
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?;
let total = items.len();
let truncated = total > 50;
items.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
})
})},
);
// ── 创建 (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"))?;
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
if !df_storage::crud::is_allowed_column("projects", field) {
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", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory",
df_ai::ai_tools::object_schema(vec![
("name", "string", true), ("description", "string", false),
("path", "string", false), ("stack", "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?;
// path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源)
let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
if let Some(path) = path_opt {
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?;
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
} else {
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), ("stack", "string", false)]),
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 stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let repo = df_storage::crud::ProjectRepo::new(&db);
let stack = bind_dir_to_project(&repo, id, path, stack_opt).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(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
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(
"update_task", "更新任务的指定字段title/description/status/priority/assignee 等),需要提供任务 ID、字段名和新值",
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"))?;
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
if !df_storage::crud::is_allowed_column("tasks", field) {
anyhow::bail!("不允许更新字段 '{}'", field);
}
let repo = df_storage::crud::TaskRepo::new(&db);
repo.update_field(id, field, value).await?;
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
})
})},
);
// FR-D6:delete_task 对齐 commands::task::delete_task —— tasks 表无 deleted_at,走硬删
// (与 delete_project 的软删不同:projects 有专门回收站机制,tasks 没有,只能硬删)
registry.register(
"delete_task", "删除任务(硬删,不可恢复。清理孤儿任务时务必用本工具,不要误用 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::TaskRepo::new(&db);
let deleted = repo.delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
})
})},
);
registry.register(
"create_idea", "捕获一个新灵感",
df_ai::ai_tools::object_schema(vec![
("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false),
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
("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 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(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
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", "删除项目(移入回收站,可恢复。永久删除用 purge_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.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
})
})},
);
registry.register(
"restore_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 restored = repo.restore(id).await?;
Ok(serde_json::json!({ "restored": restored, "id": id }))
})
})},
);
registry.register(
"purge_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 purged = repo.purge_with_descendants(id).await?;
Ok(serde_json::json!({ "purged": purged, "id": id }))
})
})},
);
registry.register(
"list_trash", "列出回收站已删除项目",
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_deleted().await?;
items.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::to_value(items)?)
})
})},
);
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(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![
("command", "string", true),
("working_dir", "string", false),
("timeout_secs", "integer", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60);
let request = ShellRequest {
command: command.to_string(),
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
};
let result = execute(request).await?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_trunc || stderr_trunc,
}))
})),
);
// ── 文件系统 ──
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!("路径含非法字符"))?;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
let mut content = String::new();
if let Err(e) = file.read_to_string(&mut content).await {
if e.kind() == std::io::ErrorKind::InvalidData {
return Ok(serde_json::json!({
"path": path, "content": null, "binary": true,
"size": metadata.len(),
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
}));
}
anyhow::bail!("读取文件失败: {}", e);
}
// limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
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).min(2000) 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), ("max_depth", "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 recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize;
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 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 参数"))?;
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if content.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
}
let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
Ok(m) if m.len() > 0 => {
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
Some(m.len())
}
Ok(_) => Some(0), // 空文件(无需备份)
Err(_) => None, // 不存在(新建)
};
if let Some(parent) = target.parent() {
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, content).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e));
}
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) {
let bak = format!("{}.bak", path);
let _ = tokio::fs::remove_file(&bak).await;
}
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
if let Some(old) = old_size {
if old > 0 && (content.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len());
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size }))
})),
);
// ── 文件元信息 (Low risk) ──
registry.register(
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
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 p = std::path::Path::new(path);
if !p.exists() {
return Ok(serde_json::json!({ "path": path, "exists": false }));
}
let metadata = tokio::fs::metadata(path).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
let is_dir = metadata.is_dir();
let size = metadata.len();
let modified = metadata.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64);
// is_binary: 读前 8KB 检测 \x00
let is_binary = if !is_dir && size > 0 {
let sample = tokio::fs::read(path).await.unwrap_or_default();
sample[..sample.len().min(8192)].contains(&0x00)
} else { false };
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
} else { None };
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
})),
);
// ── 追加写入 (Medium risk) ──
registry.register(
"append_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() {
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
let bytes = content.as_bytes();
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
})),
);
// ── 文件搜索 (Low risk) ──
registry.register(
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "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 pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let pattern_lower = pattern.to_lowercase();
const MAX_RESULTS: usize = 50;
let mut results = Vec::new();
let mut total = 0u64;
search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
let has_more = total as usize > MAX_RESULTS;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
})),
);
registry
}
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
///
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
/// - 达 `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();
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
if skip_noise && is_noise_dir(&name) {
continue;
}
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。
if skip_noise && is_noise_file(&name) {
continue;
}
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
let file_type = entry.file_type().await?;
let is_symlink = file_type.is_symlink();
let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归
result.push(serde_json::json!({
"name": name,
"type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" },
"size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) },
"depth": depth,
}));
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
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)
}
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
///
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
fn is_noise_file(name: &str) -> bool {
const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
}
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
fn search_files_recursive<'a>(
path: &'a str,
pattern: &'a str,
recursive: bool,
depth: usize,
max_depth: usize,
max_results: usize,
results: &'a mut Vec<serde_json::Value>,
total: &'a mut u64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 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? {
let name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().await?;
let is_dir = metadata.is_dir();
if !is_dir {
// 字符串包含匹配(大小写不敏感)
if name.to_lowercase().contains(pattern) {
*total += 1;
if results.len() < max_results {
let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
}
}
} else if recursive && depth < max_depth {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test]
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
// 噪音目录命中
assert!(is_noise_dir(".git"));
assert!(is_noise_dir("node_modules"));
assert!(is_noise_dir("target"));
assert!(is_noise_dir("dist"));
assert!(is_noise_dir("build"));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_dir(".gitignore"));
assert!(!is_noise_dir(".gitattributes"));
assert!(!is_noise_dir(".gitkeep"));
}
/// is_noise_dir 纯函数:普通目录不命中
#[test]
fn test_is_noise_dir_normal_dirs_not_matched() {
assert!(!is_noise_dir("src"));
assert!(!is_noise_dir("tests"));
assert!(!is_noise_dir("docs"));
assert!(!is_noise_dir("main.rs"));
assert!(!is_noise_dir(""));
}
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test]
fn test_is_noise_dir_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_dir("Target"));
assert!(!is_noise_dir("DIST"));
assert!(!is_noise_dir("NODE_MODULES"));
}
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
#[test]
fn test_is_noise_file_matches_temp_artifacts() {
// write_file 原子写副产物
assert!(is_noise_file("PROGRESS.md.bak"));
assert!(is_noise_file("PROGRESS.md.tmp-write"));
// 编辑器临时文件
assert!(is_noise_file(".main.rs.swp"));
assert!(is_noise_file("main.rs~"));
// 路径含但非后缀的应不命中(避免误杀)
assert!(!is_noise_file("backup.bak.md"));
assert!(!is_noise_file("main.rs"));
assert!(!is_noise_file(".gitignore"));
assert!(!is_noise_file(""));
}
/// 造一个临时目录树用于 list_dir_recursive 测试
fn build_noise_tree(root: &Path) {
// .git/HEAD (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap();
// node_modules/x/index.js (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("node_modules").join("x")).unwrap();
fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap();
// target/debug/bin (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("target").join("debug")).unwrap();
fs::write(root.join("target").join("debug").join("app"), "binary").unwrap();
// 普通 src/main.rs (应出现)
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap();
// .gitignore 文件 (文件名不是噪音目录,应出现)
fs::write(root.join(".gitignore"), "/target\n").unwrap();
}
/// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部)
#[tokio::test]
async fn test_list_dir_recursive_filters_noise_dirs() {
let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
assert!(!truncated);
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 噪音目录本身及其内部文件均不应出现
assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回");
assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回");
assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回");
// 噪音目录内部文件也不应被递归带入
assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现");
assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现");
assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现");
// 普通目录与 .gitignore 文件应正常返回
assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回");
assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回");
assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回");
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:skip_noise=false 时噪音目录正常返回
#[tokio::test]
async fn test_list_dir_recursive_keeps_noise_when_disabled() {
let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 关闭过滤后噪音目录应作为 entry 出现
assert!(names.contains(&".git".to_string()));
assert!(names.contains(&"node_modules".to_string()));
assert!(names.contains(&"target".to_string()));
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:max_depth 控制递归深度
#[tokio::test]
async fn test_list_dir_recursive_max_depth() {
// 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3)
let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap();
fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap();
let root = tmp.to_string_lossy().to_string();
// max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1)
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"a".to_string()), "depth1 应含 a");
assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)");
assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)");
assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs");
// max_depth=3: 应能到 depth3 的 main.rs
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs");
fs::remove_dir_all(&tmp).ok();
}
}