新增: 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:
@@ -0,0 +1,226 @@
|
||||
//! AI 工具实体参数 name→id 自动解析(机制层)
|
||||
//!
|
||||
//! 背景:DevFlow aichat 的 AI 工具要求 project_id 传 UUID,但用户/模型常用 name
|
||||
//! (如 "moyu"/"u-talk"/"DevFlow"),`repo.query("project_id", pid)` 硬匹配 UUID 全返空,
|
||||
//! 模型被迫绕 list_projects 找 UUID 浪费轮次。
|
||||
//!
|
||||
//! 本模块在工具执行前统一做 name→id 解析:
|
||||
//! - 声明式映射表 [`RESOLVE_MAP`]:新工具需按 name 解析,只需在表里登记一行即生效
|
||||
//! (工具名 + 参数键名 + 实体类型),无框架/无配置。
|
||||
//! - UUID 形态跳过:已是 id 的值直接透传,不查库(零额外开销)。
|
||||
//! - 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`),
|
||||
//! 命中唯一则替换为项目 id;0 命中 / 重名(≥2)返可行动错误,让 LLM 拿提示自修。
|
||||
//!
|
||||
//! 调用点:audit/mod.rs process_tool_calls 单点漏斗(auto + 审批 + 目录授权全部执行路径
|
||||
//! 统一拿到已解析 id)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::db::Database;
|
||||
|
||||
/// 实体类型:后续扩展 Task/Idea 时在此加枚举变体即可。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum EntityType {
|
||||
Project,
|
||||
}
|
||||
|
||||
/// 声明式映射表:工具名 → [(参数键名, 实体类型)]。
|
||||
/// 需按 name 解析的工具在此登记一行即生效。
|
||||
pub(crate) static RESOLVE_MAP: &[(&str, &[(&str, EntityType)])] = &[
|
||||
("list_tasks", &[("project_id", EntityType::Project)]),
|
||||
("create_task", &[("project_id", EntityType::Project)]),
|
||||
("get_task_count", &[("project_id", EntityType::Project)]),
|
||||
("get_project_timeline", &[("project_id", EntityType::Project)]),
|
||||
("add_project_service", &[("project_id", EntityType::Project)]),
|
||||
("list_project_services",&[("project_id", EntityType::Project)]),
|
||||
("list_project_modules", &[("project_id", EntityType::Project)]),
|
||||
("update_project", &[("id", EntityType::Project)]),
|
||||
("bind_directory", &[("id", EntityType::Project)]),
|
||||
("delete_project", &[("id", EntityType::Project)]),
|
||||
("restore_project", &[("id", EntityType::Project)]),
|
||||
("purge_project", &[("id", EntityType::Project)]),
|
||||
];
|
||||
|
||||
/// 判定字符串是否为 UUID 形态(`Uuid::parse_str` 支持 simple/hyphenated/urn/braced 四格式)。
|
||||
///
|
||||
/// **严禁用 `contains('-')` 判定**——"u-talk"/"df-relay" 是真实项目名带连字符,会被误判为 UUID。
|
||||
fn is_uuid_shape(s: &str) -> bool {
|
||||
uuid::Uuid::parse_str(s).is_ok()
|
||||
}
|
||||
|
||||
/// 解析工具参数中的实体 name→id。
|
||||
///
|
||||
/// 规则(与调用点协商的契约):
|
||||
/// - 工具未在 [`RESOLVE_MAP`] 登记 / 参数缺失 / 值非字符串 → 原样 `Ok(args.clone())`
|
||||
/// (不拦截,交给工具 handler 正常处理)。
|
||||
/// - 值是 UUID 形态 → 已是 id,跳过(不查库)。
|
||||
/// - 非 UUID → 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`)。
|
||||
/// 命中唯一 → 替换该键为项目 id(保持其余字段);0 命中 / 重名(≥2)→ Err(结构化错误,
|
||||
/// 由调用点包 failed envelope 回传 LLM,让模型拿可行动提示自修)。
|
||||
pub(crate) async fn resolve_entity_ids(
|
||||
db: &Arc<Database>,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
// 映射表未登记该工具 → 原样透传(零侵入)
|
||||
let Some((_, key_entities)) = RESOLVE_MAP.iter().find(|(name, _)| *name == tool_name) else {
|
||||
return Ok(args.clone());
|
||||
};
|
||||
|
||||
let mut resolved = args.clone();
|
||||
// 遍历该工具登记的所有实体参数键(当前每个工具 1 键,通用支持多键)
|
||||
for (key, entity_type) in *key_entities {
|
||||
// 参数缺失 / 值非字符串 → 跳过(工具 handler 自有缺参语义,不在此拦截)
|
||||
let Some(value) = resolved.get(*key).and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
// UUID 形态 → 已是 id,跳过(不查库,零额外开销)
|
||||
if is_uuid_shape(value) {
|
||||
continue;
|
||||
}
|
||||
// 按 name 精确解析:list_active 排软删 + 内存过滤 p.name == value
|
||||
let id = match *entity_type {
|
||||
EntityType::Project => resolve_project_name(db, tool_name, key, value).await?,
|
||||
};
|
||||
// 替换该键为项目 id,保持其余字段
|
||||
if let Some(obj) = resolved.as_object_mut() {
|
||||
obj.insert(key.to_string(), serde_json::Value::String(id));
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// 按 name 精确解析 active 项目(list_active 排软删),返回唯一命中的项目 id。
|
||||
/// 0 命中 / 重名(≥2)返可行动错误(含工具名 + 参数键 + 值),让 LLM 自修。
|
||||
async fn resolve_project_name(
|
||||
db: &Arc<Database>,
|
||||
tool_name: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let projects = ProjectRepo::new(db).list_active().await?;
|
||||
let matches: Vec<_> = projects.iter().filter(|p| p.name == value).collect();
|
||||
match matches.len() {
|
||||
0 => Err(anyhow::anyhow!(
|
||||
"工具 {} 的参数 {}={} 解析失败:未找到名为「{}」的项目(精确匹配,含空格/大小写)。可先调用 list_projects 核对项目名",
|
||||
tool_name, key, value, value
|
||||
)),
|
||||
1 => Ok(matches[0].id.clone()),
|
||||
_ => {
|
||||
// 重名:列出前 2 个(含 id+path),引导用 list_projects 区分后传具体项目 id
|
||||
let a = matches[0];
|
||||
let b = matches[1];
|
||||
Err(anyhow::anyhow!(
|
||||
"工具 {} 的参数 {}={} 命中多个同名项目:「{}」(id={}, path={})、「{}」(id={}, path={})。请用 list_projects 区分后传具体项目 id",
|
||||
tool_name, key, value,
|
||||
a.name, a.id, a.path.clone().unwrap_or_default(),
|
||||
b.name, b.id, b.path.clone().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::models::ProjectRecord;
|
||||
use df_types::types::{ProjectStatus, new_id};
|
||||
|
||||
/// 内存 DB + 插一条项目,返回 (db, project_id)
|
||||
async fn seed_project(db: &Arc<Database>, name: &str) -> String {
|
||||
let now = df_types::now_millis().to_string();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name: name.to_owned(),
|
||||
description: String::new(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: Some(format!("C:/projects/{}", name)),
|
||||
stack: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
ProjectRepo::new(db).insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_uuid_shape_table() {
|
||||
let real_uuid = df_types::types::new_id();
|
||||
assert!(is_uuid_shape(&real_uuid), "真 UUID v4 应为 true");
|
||||
for s in ["u-talk", "moyu", "proj-svc-1", ""] {
|
||||
assert!(!is_uuid_shape(s), "「{}」不应判为 UUID", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_unknown_tool_passthrough() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let args = serde_json::json!({ "path": "/tmp/x.rs" });
|
||||
let out = resolve_entity_ids(&db, "read_file", &args).await.unwrap();
|
||||
assert_eq!(out, args, "未登记工具应原样透传");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_unique_name_to_id() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let pid = seed_project(&db, "moyu").await;
|
||||
let args = serde_json::json!({ "project_id": "moyu", "status": "todo" });
|
||||
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
|
||||
assert_eq!(out["project_id"], serde_json::Value::String(pid));
|
||||
assert_eq!(out["status"], "todo", "其余字段应保持不变");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_uuid_passthrough() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let uuid = df_types::types::new_id();
|
||||
let args = serde_json::json!({ "project_id": uuid });
|
||||
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
|
||||
assert_eq!(out, args, "已是 UUID 应原样透传(不查库)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_missing_param_passthrough() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let args = serde_json::json!({ "status": "todo" });
|
||||
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
|
||||
assert_eq!(out, args, "参数缺失应原样透传");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_zero_hit_errors() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let args = serde_json::json!({ "project_id": "不存在" });
|
||||
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("未找到"), "0 命中应报「未找到」,实际: {}", msg);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_duplicate_name_errors() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let pid1 = seed_project(&db, "DevFlow").await;
|
||||
let pid2 = seed_project(&db, "DevFlow").await;
|
||||
let args = serde_json::json!({ "project_id": "DevFlow" });
|
||||
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("命中多个同名项目"), "重名应报,实际: {}", msg);
|
||||
assert!(msg.contains(&pid1), "错误应含第一个 id,实际: {}", msg);
|
||||
assert!(msg.contains(&pid2), "错误应含第二个 id,实际: {}", msg);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_soft_deleted_excluded() {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
let pid = seed_project(&db, "回收站项目").await;
|
||||
ProjectRepo::new(&db).soft_delete(&pid).await.unwrap();
|
||||
let args = serde_json::json!({ "project_id": "回收站项目" });
|
||||
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("未找到"),
|
||||
"软删项目不应解析到,实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user