新增: MCP HTTP transport + 系统托盘 + 实体解析(会话前基线收尾)

- df-mcp 加 streamable HTTP transport 层(axum 0.7 + tower,server_http.rs),lib.rs pub mod 接线

- src-tauri 加 mcp.rs(spawn_mcp_http + mcp_get_status IPC + mcp-server CLI)+ tray.rs(系统托盘 show_main/setup_tray),lib.rs 集成 + main.rs mcp-server 参数路由

- AI 工具加 entity_resolve.rs(实体解析,tools/mod.rs pub mod 接线)
This commit is contained in:
lxy
2026-08-05 22:14:27 +08:00
parent c480627ba6
commit a91e950874
16 changed files with 2238 additions and 94 deletions
+124 -65
View File
@@ -12,7 +12,8 @@
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
use std::sync::Arc;
// name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。
use std::sync::{Arc, OnceLock};
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database;
@@ -94,35 +95,39 @@ fn int_field(desc: &str) -> Value {
// ============================================================
/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。
pub fn all_tools() -> Vec<&'static ToolSpec> {
use RiskLevel::*;
vec![
// ─── 项目 ───
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), Medium, update_project),
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_idea),
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_idea),
// ─── 工作流(High) ───
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
// ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
]
static TOOLS: OnceLock<Vec<&'static ToolSpec>> = OnceLock::new();
pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
TOOLS.get_or_init(|| {
use RiskLevel::*;
vec![
// ─── 项目 ───
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name", "description"]), Medium, create_project),
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), Medium, update_project),
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("任务 ID(可空)")}), &["project_id", "title", "description"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_idea),
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_idea),
// ─── 工作流(High) ───
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
// ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
]
})
}
/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。
@@ -151,7 +156,7 @@ fn spec(
/// 按 name 查找工具(线性扫描,工具数 20,O(n) 足够)。
pub fn find(name: &str) -> Option<&'static ToolSpec> {
all_tools().into_iter().find(|t| t.tool.name == name)
all_tools().iter().find(|t| t.tool.name == name).copied()
}
// ============================================================
@@ -291,7 +296,12 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("create_project", &name);
Box::pin(async move {
let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let status = match ProjectStatus::from_db_str(&status) {
Some(s) => s,
None => return CallToolResult::error(
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let rec = ProjectRecord {
id: new_id(),
name,
@@ -339,8 +349,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned());
let status = match ProjectStatus::from_db_str(&status) {
Some(s) => s,
None => return CallToolResult::error(
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord {
id: id.clone(),
name,
@@ -398,8 +413,10 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
));
}
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}"));
match repo.update_field(&id, "path", &norm).await {
Ok(true) => {}
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e),
}
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
@@ -416,16 +433,20 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
Box::pin(async move {
let repo = TaskRepo::new(&db);
match repo.list_active().await {
Ok(mut list) => {
if let Some(pid) = &project_id_filter {
list.retain(|t| t.project_id == *pid);
}
if let Some(st) = &status_filter {
list.retain(|t| t.status.as_str() == st.as_str());
}
json_ok(json!({ "tasks": list, "count": list.len() }))
}
let query = df_storage::crud::TaskQuery {
project_id: project_id_filter,
status: status_filter,
priority: None,
assignee: None,
keyword: None,
queue: None,
parent_id: None,
order_by: None,
limit: None,
offset: None,
};
match repo.list_by_query(&query).await {
Ok(list) => json_ok(json!({ "tasks": list, "count": list.len() })),
Err(e) => err_str(e),
}
})
@@ -445,6 +466,28 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let priority = arg_int_or(&args, "priority", 0);
medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move {
// parent_id 可选(arg_str_or 给 "" 哨兵,空串视为 None)。非空时校验 1 级嵌套铁律:
// 父任务存在 + 父任务自身无 parent_id(防孙任务),违反返回明确错误(与 IPC create_task 同规则)。
let parent_id_raw = arg_str_or(&args, "parent_id", "");
let parent_id = if parent_id_raw.trim().is_empty() {
None
} else {
let pid = parent_id_raw.trim();
let repo = TaskRepo::new(&db);
match repo.get_by_id(pid).await {
Ok(Some(parent)) => {
if parent.parent_id.is_some() {
return CallToolResult::error(format!(
"父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}",
parent.parent_id
));
}
Some(pid.to_string())
}
Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")),
Err(e) => return err_str(e),
}
};
let now = now_millis();
let rec = TaskRecord {
id: new_id(),
@@ -461,7 +504,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
output_json: None,
idea_id: None,
queue: "todo".to_string(),
parent_id: None,
parent_id,
content_json: None,
created_at: now.clone(),
updated_at: now,
@@ -549,12 +592,13 @@ fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
medium_audit("advance_task", &format!("{id} -> {to}"));
Box::pin(async move {
let repo = TaskRepo::new(&db);
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源,设计 D3 消除双轨):
// - is_valid_state + can_transition + 同态拒绝三层校验
// - CAS 防 TOCTOU
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
// - advance_task_with_parent:子任务推进后自动触发父 status 聚合(聚合失败仅 warn 不阻断)
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
match df_nodes::task_advance_node::advance_task_with_parent(&repo, &id, &to).await {
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
Err(e) => err_str(e),
}
@@ -725,7 +769,10 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let now = now_millis();
// 写回 scores 字段(整体更新)
let mut rec = idea.clone();
rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
rec.scores = Some(match serde_json::to_string(&scores) {
Ok(s) => s,
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
});
rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await {
return err_str(e);
@@ -738,11 +785,10 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
fn heuristic_scores(title: &str, description: &str) -> Value {
let desc_len = description.chars().count();
let title_len = title.chars().count();
// feasibility:描述越详细越可行(评估前已有思考)
let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0);
// impact:含「核心/关键/重要」等关键词加权
let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
let impact_keywords: &[&str] = &["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0);
// urgency:priority 字段不在此,用关键词近似
@@ -751,7 +797,6 @@ fn heuristic_scores(title: &str, description: &str) -> Value {
let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0);
// overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2)
let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2;
let _ = title_len; // 标题长度暂不入分(避免短标题被低估)
json!({
"feasibility": (feasibility * 10.0).round() / 10.0,
"impact": (impact * 10.0).round() / 10.0,
@@ -815,13 +860,32 @@ fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
// ============================================================
fn normalize_path(p: &str) -> String {
match std::path::Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
// 先尝试 canonicalize(解析符号链接 + 绝对化 + .. 折叠,仅已存在的路径有效)
if let Ok(abs) = std::path::Path::new(p).canonicalize() {
return abs.to_string_lossy().replace('\\', "/").to_lowercase();
}
// fallback:路径尚未创建,手动做以下处理:
// ① 统一分隔符
// ② 逐段折叠 ..(防 foo/../bar → foo/bar)
// ③ 去尾斜杠
// ④ 小写化
let normalized = p.replace('\\', "/");
let mut segments: Vec<&str> = Vec::new();
for seg in normalized.split('/') {
match seg {
"." | "" => continue, // 当前目录 / 空段(连续斜杠)
".." if segments.is_empty() => segments.push(".."), // 根级 .. 保留(相对路径语义)
".." => { segments.pop(); } // 上级 → 弹出上一段
_ => segments.push(seg),
}
}
let result = if segments.is_empty() {
String::new()
} else {
segments.join("/")
};
// 去尾斜杠
result.trim_end_matches('/').to_lowercase()
}
// ============================================================
@@ -1035,14 +1099,9 @@ mod tests {
assert!(visible_for_test(false, "score_idea"));
}
// 辅助:复用 server.rs 的 visible 谓词语义(本地重写,避免跨模块私有依赖)
// 辅助:复用 server.rs 的 visible 谓词(单一事实来源,避免两份逻辑漂移)
fn visible_for_test(read_only: bool, name: &str) -> bool {
let spec = find(name).expect("工具存在");
if read_only {
spec.risk == RiskLevel::Low
} else {
spec.risk != RiskLevel::High
}
crate::server::visible(read_only, find(name).expect("工具存在").risk)
}
// ── heuristic_scores 纯函数:两工具共用,确定性 ──────────────────