From a91e95087489c1aaba2b5509f9e9567cc2f8def4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 5 Aug 2026 22:14:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20MCP=20HTTP=20transport=20?= =?UTF-8?q?+=20=E7=B3=BB=E7=BB=9F=E6=89=98=E7=9B=98=20+=20=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E8=A7=A3=E6=9E=90(=E4=BC=9A=E8=AF=9D=E5=89=8D?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E6=94=B6=E5=B0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 接线) --- Cargo.lock | 19 + crates/df-mcp/Cargo.toml | 4 + crates/df-mcp/src/lib.rs | 1 + crates/df-mcp/src/protocol.rs | 3 + crates/df-mcp/src/server.rs | 32 +- crates/df-mcp/src/server_http.rs | 339 +++++ crates/df-mcp/src/tools.rs | 189 ++- package-lock.json | 1251 +++++++++++++++++ package.json | 1 + src-tauri/Cargo.toml | 5 +- src-tauri/build.rs | 19 +- .../src/commands/ai/tools/entity_resolve.rs | 226 +++ src-tauri/src/lib.rs | 33 + src-tauri/src/main.rs | 62 +- src-tauri/src/mcp.rs | 99 ++ src-tauri/src/tray.rs | 49 + 16 files changed, 2238 insertions(+), 94 deletions(-) create mode 100644 crates/df-mcp/src/server_http.rs create mode 100644 src-tauri/src/commands/ai/tools/entity_resolve.rs create mode 100644 src-tauri/src/mcp.rs create mode 100644 src-tauri/src/tray.rs diff --git a/Cargo.lock b/Cargo.lock index 31f755b..5bb39c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-plugin-window-state", "tokio", "tracing", @@ -949,6 +950,7 @@ name = "df-mcp" version = "0.1.0" dependencies = [ "anyhow", + "axum", "df-nodes", "df-storage", "df-types", @@ -956,6 +958,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tower", "tracing", "uuid", ] @@ -4666,6 +4669,22 @@ dependencies = [ "zbus 5.16.0", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus 5.16.0", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" diff --git a/crates/df-mcp/Cargo.toml b/crates/df-mcp/Cargo.toml index 5dbb25f..0cef617 100644 --- a/crates/df-mcp/Cargo.toml +++ b/crates/df-mcp/Cargo.toml @@ -18,6 +18,10 @@ anyhow.workspace = true tracing.workspace = true uuid.workspace = true futures = "0.3" +# HTTP (streamable HTTP) transport 层:axum Router(workspace 已锁 0.7.9,零新依赖树) +axum = { version = "0.7" } [dev-dependencies] tokio = { workspace = true, features = ["full", "test-util"] } +# 单测用 tower::ServiceExt::oneshot 直接打 Router +tower = { version = "0.5", features = ["util"] } diff --git a/crates/df-mcp/src/lib.rs b/crates/df-mcp/src/lib.rs index 19069f9..c3a233a 100644 --- a/crates/df-mcp/src/lib.rs +++ b/crates/df-mcp/src/lib.rs @@ -14,6 +14,7 @@ pub mod protocol; pub mod server; +pub mod server_http; pub mod tools; pub use server::run_server; diff --git a/crates/df-mcp/src/protocol.rs b/crates/df-mcp/src/protocol.rs index d572100..c9b592c 100644 --- a/crates/df-mcp/src/protocol.rs +++ b/crates/df-mcp/src/protocol.rs @@ -118,6 +118,9 @@ impl McpMethod { .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); + if name.is_empty() { + return McpMethod::Unknown("tools/call missing required 'name' parameter".to_owned()); + } let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null); McpMethod::ToolsCall { name, arguments } } diff --git a/crates/df-mcp/src/server.rs b/crates/df-mcp/src/server.rs index a468cc3..bf4fac0 100644 --- a/crates/df-mcp/src/server.rs +++ b/crates/df-mcp/src/server.rs @@ -23,9 +23,9 @@ use crate::protocol::{ use crate::tools::{self, Ctx, RiskLevel}; /// 协议版本(MCP 2025-06-18) -const PROTOCOL_VERSION: &str = "2025-06-18"; -const SERVER_NAME: &str = "devflow-mcp"; -const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18"; +pub(crate) const SERVER_NAME: &str = "devflow-mcp"; +pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); /// 启动 MCP server。 /// @@ -79,7 +79,7 @@ where Err(e) => { // 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败"); - let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None); + let resp = Response::err(Some(Value::Null), crate::protocol::PARSE_ERROR, "Parse error", None); write_response(&mut writer, &resp).await?; continue; } @@ -111,7 +111,8 @@ where /// 方法分发 → 构造 Response。 /// /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 -async fn dispatch(ctx: &Ctx, read_only: bool, id: Option, method: McpMethod) -> Response { +/// `pub(crate)`:stdio(main_loop)与 HTTP(server_http)transport 共用。 +pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option, method: McpMethod) -> Response { match method { McpMethod::Initialize { .. } => { let result = InitializeResult { @@ -135,9 +136,10 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option, method: McpMeth } McpMethod::ToolsList => { let tools: Vec<_> = tools::all_tools() - .into_iter() + .iter() .filter(|t| visible(read_only, t.risk)) .map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null)) + .filter(|v| !v.is_null()) .collect(); Response::ok(id, json!({ "tools": tools })) } @@ -180,7 +182,7 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option, method: McpMeth } /// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见) -fn visible(read_only: bool, risk: RiskLevel) -> bool { +pub(crate) fn visible(read_only: bool, risk: RiskLevel) -> bool { if read_only { risk == RiskLevel::Low } else { @@ -287,6 +289,22 @@ mod tests { assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND); } + #[tokio::test] + async fn tools_call_missing_name_returns_clear_error() { + // tools/call 缺 name 参数:不应回「未知工具: 」(空名),应回明确 METHOD_NOT_FOUND + let input = + r#"{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"arguments":{}}}"#; + let out = run_io_lines(&[input], false).await; + let v: Value = serde_json::from_str(&out[0]).unwrap(); + assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND); + let msg = v["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("name"), + "空 name 应给出明确提示,实际: {msg}" + ); + assert!(!msg.contains("未知工具: "), "不应是空名「未知工具: 」: {msg}"); + } + #[tokio::test] async fn tools_call_high_risk_is_rejected() { let input = diff --git a/crates/df-mcp/src/server_http.rs b/crates/df-mcp/src/server_http.rs new file mode 100644 index 0000000..29e1e94 --- /dev/null +++ b/crates/df-mcp/src/server_http.rs @@ -0,0 +1,339 @@ +//! HTTP (streamable HTTP, 2025-06-18) transport 层。 +//! +//! 桌面进程内嵌 server 的传输层:POST /mcp 单响应 JSON;GET 405;notification 202 无 body。 +//! 与 stdio transport 共享同一 dispatch/handler/Ctx,纯 df-mcp 内部实现,零 tauri 依赖。 +//! +//! 协议合规(streamable HTTP 2025-06-18): +//! - POST /mcp → `application/json` 单响应(dispatch 层错误随 JSON-RPC 错误体回,HTTP 仍 200) +//! - GET /mcp → 405 + `Allow: POST` +//! - notification(id 缺省)→ 202 Accepted 无 body +//! - batch 数组 body → 400 + INVALID_REQUEST(本 server 不支持 batch) +//! - 非法 JSON body → 400 + PARSE_ERROR +//! - 无状态 server:忽略 Mcp-Session-Id 头(合规) +//! +//! 桌面端注入方式:构造 [`McpHttpState`] 传入共享 db + on_tool_call 回调, +//! [`build_router`] 得 Router,再 `axum::serve(listener, router)` 常驻监听。 + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::State, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use df_storage::db::Database; +use serde_json::{json, Value}; + +use crate::protocol::{McpMethod, Request, INVALID_REQUEST, PARSE_ERROR}; +use crate::server::dispatch; +use crate::tools::Ctx; + +/// 默认端口(桌面内嵌固定端口;可用 env `DEVFLOW_MCP_PORT` 覆盖)。 +pub const DEFAULT_MCP_PORT: u16 = 18765; + +/// HTTP MCP 共享状态(axum State)。 +pub struct McpHttpState { + pub ctx: Ctx, + pub read_only: bool, + /// 成功 tools/call 回调(工具名)。桌面端注入 → emit df-data-changed;None=不回调。 + pub on_tool_call: Option>, +} + +impl McpHttpState { + pub fn new( + db: Arc, + read_only: bool, + on_tool_call: Option>, + ) -> Self { + Self { + ctx: Ctx::new(db), + read_only, + on_tool_call, + } + } +} + +/// 构造 axum Router(/mcp 单路由:POST 处理请求,GET 回 405)。 +pub fn build_router(state: McpHttpState) -> Router { + Router::new() + .route("/mcp", post(post_mcp).get(get_mcp)) + .with_state(Arc::new(state)) +} + +/// 在已绑定 listener 上启动 HTTP server(永不返回直至 shutdown)。 +/// +/// 桌面端用法:bind 127.0.0.1:18765 → spawn(serve_on(listener, state)), +/// 进程内常驻,多 Claude 会话 HTTP 直连同一进程。 +pub async fn serve_on(listener: tokio::net::TcpListener, state: McpHttpState) -> anyhow::Result<()> { + axum::serve(listener, build_router(state)) + .await + .map_err(|e| anyhow::anyhow!("axum::serve 失败: {e}")) +} + +/// POST /mcp:解析 body → dispatch → JSON 单响应 / 202 notification。 +async fn post_mcp(State(state): State>, body: Bytes) -> Response { + match handle_body(&state, &body).await { + Outcome::Json(status, value) => (status, axum::Json(value)).into_response(), + Outcome::Accepted => StatusCode::ACCEPTED.into_response(), + } +} + +/// GET /mcp → 405 + `Allow: POST`(streamable HTTP 规范)。 +async fn get_mcp() -> Response { + let mut resp = StatusCode::METHOD_NOT_ALLOWED.into_response(); + resp.headers_mut() + .insert(header::ALLOW, HeaderValue::from_static("POST")); + resp +} + +/// 分发结果:单响应 JSON / notification 202。 +enum Outcome { + /// 单响应(HTTP 状态码 + JSON-RPC body) + Json(StatusCode, Value), + /// notification:202 Accepted,无 body + Accepted, +} + +/// 解析请求体并分发到共享 dispatch。 +async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome { + // ① UTF-8 校验 + let text = match std::str::from_utf8(body) { + Ok(t) => t, + Err(_) => { + return Outcome::Json( + StatusCode::BAD_REQUEST, + rpc_error(None, PARSE_ERROR, "Parse error: 请求体不是合法 UTF-8"), + ); + } + }; + + // ② 整体 JSON 解析:数组 = batch,本 server 不支持 + let raw: Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => { + return Outcome::Json( + StatusCode::BAD_REQUEST, + rpc_error(None, PARSE_ERROR, "Parse error: 非法 JSON"), + ); + } + }; + if raw.is_array() { + return Outcome::Json( + StatusCode::BAD_REQUEST, + rpc_error(None, INVALID_REQUEST, "Invalid request: batch 请求不支持"), + ); + } + + // ③ 解析为 JSON-RPC Request(结构非法 → INVALID_REQUEST) + let req: Request = match serde_json::from_value(raw) { + Ok(r) => r, + Err(_) => { + return Outcome::Json( + StatusCode::BAD_REQUEST, + rpc_error(None, INVALID_REQUEST, "Invalid request"), + ); + } + }; + + let method = McpMethod::from_request(&req); + let is_notification = req.id.is_none(); + + // ④ notification(id 缺省)→ 202 Accepted 无 body + if is_notification { + match method { + McpMethod::Initialized => { + tracing::debug!(target: "df_mcp", "HTTP: 客户端 initialized 通知已收"); + } + _ => { + tracing::debug!(target: "df_mcp", m = ?method, "HTTP: 忽略未识别通知"); + } + } + return Outcome::Accepted; + } + + // ⑤ 工具名预取(tools/call 成功回调用;method 随后 move 进 dispatch) + let tool_name = match &method { + McpMethod::ToolsCall { name, .. } => Some(name.clone()), + _ => None, + }; + + let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await; + + // ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI) + if resp.error.is_none() { + if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) { + cb(&name); + } + } + + let value = serde_json::to_value(&resp) + .unwrap_or_else(|_| rpc_error(req.id, crate::protocol::INTERNAL_ERROR, "响应序列化失败")); + Outcome::Json(StatusCode::OK, value) +} + +/// 构造 JSON-RPC 2.0 错误响应体。 +fn rpc_error(id: Option, code: i32, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }) +} + +// ============================================================ +// 单测:tower oneshot 打 Router + open_in_memory DB +// 覆盖:initialize / tools/list / notification 202 / GET 405 / 非法 JSON / batch / on_tool_call +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + }; + use std::sync::Mutex; + use tower::ServiceExt; + + /// 构造内存 DB + McpHttpState(on_tool_call 可注入) + async fn test_state( + read_only: bool, + on_tool_call: Option>, + ) -> McpHttpState { + let db = Arc::new(Database::open_in_memory().await.unwrap()); + McpHttpState::new(db, read_only, on_tool_call) + } + + /// oneshot 打 Router:method + body → (status, json body, headers) + async fn send( + router: Router, + method: &str, + body: &str, + ) -> (StatusCode, Value, axum::http::HeaderMap) { + let builder = Request::builder().uri("/mcp").method(method); + let builder = if method == "GET" { + builder + } else { + builder.header(header::CONTENT_TYPE, "application/json") + }; + let req = builder.body(Body::from(body.to_string())).unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let value: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + }; + (status, value, headers) + } + + #[tokio::test] + async fn post_initialize_returns_server_info_and_capabilities() { + let state = test_state(false, None).await; + let router = build_router(state); + let (status, v, _) = send( + router, + "POST", + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 1); + assert_eq!(v["result"]["protocolVersion"], crate::server::PROTOCOL_VERSION); + assert_eq!(v["result"]["serverInfo"]["name"], crate::server::SERVER_NAME); + assert!(v["result"]["capabilities"]["tools"].is_object()); + } + + #[tokio::test] + async fn post_tools_list_excludes_high_risk_by_default() { + let state = test_state(false, None).await; + let router = build_router(state); + let (status, v, _) = send( + router, + "POST", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#, + ) + .await; + assert_eq!(status, StatusCode::OK); + let tools = v["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + // 默认(非 read-only):Low + Medium 可见,High 不可见 + assert!(names.contains(&"list_projects")); + assert!(names.contains(&"create_project")); // Medium + assert!(!names.contains(&"delete_project")); // High + assert!(!names.contains(&"run_workflow")); // High + } + + #[tokio::test] + async fn post_notification_returns_202_no_body() { + let state = test_state(false, None).await; + let router = build_router(state); + let (status, v, _) = send( + router, + "POST", + r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#, + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED); + assert_eq!(v, Value::Null); + } + + #[tokio::test] + async fn get_mcp_returns_405_allow_post() { + let state = test_state(false, None).await; + let router = build_router(state); + let (status, _, headers) = send(router, "GET", "").await; + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(headers.get(header::ALLOW).unwrap(), "POST"); + } + + #[tokio::test] + async fn post_invalid_json_returns_400_parse_error() { + let state = test_state(false, None).await; + let router = build_router(state); + let (status, v, _) = send(router, "POST", "not json").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(v["error"]["code"], PARSE_ERROR); + } + + #[tokio::test] + async fn post_batch_returns_400_invalid_request() { + let state = test_state(false, None).await; + let router = build_router(state); + let body = r#"[{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}]"#; + let (status, v, _) = send(router, "POST", body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(v["error"]["code"], INVALID_REQUEST); + } + + #[tokio::test] + async fn tools_call_success_triggers_on_tool_call() { + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let calls_cb = calls.clone(); + let on_tool_call: Option> = Some(Arc::new(move |name| { + calls_cb.lock().unwrap().push(name.to_string()); + })); + let state = test_state(false, on_tool_call).await; + let router = build_router(state); + let body = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"HttpProj","description":"via http"}}}"#; + let (status, v, _) = send(router, "POST", body).await; + assert_eq!(status, StatusCode::OK); + // 成功业务响应:isError 不置位 + assert!( + v["result"]["isError"].is_null() || v["result"]["isError"] == Value::Bool(false) + ); + assert!( + calls.lock().unwrap().contains(&"create_project".to_string()), + "成功 tools/call 应触发 on_tool_call,实际: {:?}", + *calls.lock().unwrap() + ); + } +} diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index cf580fd..38180a4 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -12,7 +12,8 @@ //! handler 形态:`fn(&Ctx, Value) -> BoxFuture`(函数指针 + async 块), //! 避免闭包捕获带来的 Box 开销与生命周期问题。 -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> = 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 纯函数:两工具共用,确定性 ────────────────── diff --git a/package-lock.json b/package-lock.json index 40d7e1d..f823aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,19 @@ "name": "devflow", "version": "0.1.0", "dependencies": { + "@antv/layout": "^2.0.0", "@antv/x6": "^3.1.7", "@antv/x6-vue-shape": "^3.0.2", "@arco-design/web-vue": "^2.58.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", + "@types/dagre": "^0.7.54", + "dagre": "^0.8.5", "dompurify": "^3.4.10", "highlight.js": "^11.11.1", "marked": "^18.0.5", + "mermaid": "^11.16.0", + "tslib": "^2.8.1", "vue": "^3.5.13", "vue-i18n": "9", "vue-router": "4" @@ -29,6 +34,71 @@ "vue-tsc": "^2.2.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antv/event-emitter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", + "integrity": "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==", + "license": "MIT" + }, + "node_modules/@antv/expr": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", + "integrity": "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==", + "license": "MIT" + }, + "node_modules/@antv/graphlib": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", + "integrity": "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w==", + "license": "MIT", + "dependencies": { + "@antv/event-emitter": "^0.1.3" + } + }, + "node_modules/@antv/layout": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", + "integrity": "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw==", + "license": "MIT", + "dependencies": { + "@antv/event-emitter": "^0.1.3", + "@antv/expr": "^1.0.2", + "@antv/graphlib": "^2.0.0", + "@antv/util": "^3.3.2", + "comlink": "^4.4.1", + "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.5", + "d3-octree": "^1.0.2", + "d3-quadtree": "^3.0.1", + "dagre": "^0.8.5", + "ml-matrix": "^6.10.4", + "tslib": "^2.8.1" + } + }, + "node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, "node_modules/@antv/x6": { "version": "3.1.7", "resolved": "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", @@ -164,6 +234,18 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -606,6 +688,23 @@ "node": ">=18" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@intlify/core-base": { "version": "9.14.4", "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz", @@ -656,6 +755,15 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.0", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", @@ -1242,6 +1350,265 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", @@ -1260,6 +1627,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1267,6 +1640,16 @@ "license": "MIT", "optional": true }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1523,18 +1906,579 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "license": "Apache-2.0" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", @@ -1548,6 +2492,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dom-align": { "version": "1.12.4", "resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", @@ -1575,6 +2528,17 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", @@ -1623,6 +2587,12 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -1656,6 +2626,27 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", @@ -1675,12 +2666,91 @@ "node": ">=12.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-any-array": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", + "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", + "license": "MIT" + }, "node_modules/is-arrayish": { "version": "0.3.4", "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", @@ -1708,6 +2778,47 @@ "node": ">= 20" } }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", @@ -1724,6 +2835,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ml-array-max": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", + "integrity": "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0" + } + }, + "node_modules/ml-array-min": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", + "integrity": "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0" + } + }, + "node_modules/ml-array-rescale": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", + "integrity": "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0", + "ml-array-max": "^2.0.0", + "ml-array-min": "^2.0.0" + } + }, + "node_modules/ml-matrix": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.14.0.tgz", + "integrity": "sha512-5W31+w+6jIm05l85N3Ik04fl+5LfN1lyJLBrEM/r/j7YmvwRclcUyQGXGFWXnN7ASzVjsAnAa3FUXrduAt168A==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0", + "ml-array-rescale": "^2.0.0" + } + }, "node_modules/mousetrap": { "version": "1.6.5", "resolved": "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", @@ -1761,6 +2911,12 @@ "integrity": "sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ==", "license": "MIT" }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1768,6 +2924,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -1787,6 +2949,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", @@ -1821,6 +2999,12 @@ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.0.tgz", @@ -1866,6 +3050,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scroll-into-view-if-needed": { "version": "2.2.31", "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", @@ -1893,6 +3101,21 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1910,6 +3133,21 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", @@ -1933,6 +3171,19 @@ "node": ">= 4" } }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", diff --git a/package.json b/package.json index f5ebeb7..057242c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dompurify": "^3.4.10", "highlight.js": "^11.11.1", "marked": "^18.0.5", + "mermaid": "^11.16.0", "tslib": "^2.8.1", "vue": "^3.5.13", "vue-i18n": "9", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d4056f..6a09f8f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,10 +13,13 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +# Phase3 桌面内嵌 HTTP MCP server + 托盘常驻:tray-icon feature 非默认,必须显式启用 +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-window-state = "2" +# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口 +tauri-plugin-single-instance = "2" serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..1caf966 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,20 @@ fn main() { - tauri_build::build() + tauri_build::build(); + + // 从 tauri.conf.json 读取 identifier,注入编译时常量 MCP_APP_IDENTIFIER。 + // CLI 模式(mcp-server)无 tauri app handle,无法通过 app.path().app_data_dir() 获取 + // 真实路径;此常量让 default_db_path 自动拼出 Tauri 实际使用的 APPDATA/{identifier}/ 路径, + // 保证 MCP server 与 GUI 应用读写同一数据库,无需手动传 --db。 + let conf_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tauri.conf.json"); + if let Ok(content) = std::fs::read_to_string(&conf_path) { + if let Some(identifier) = content + .lines() + .find(|l| l.contains("\"identifier\"")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().trim_matches(',').trim_matches('"')) + { + println!("cargo:rustc-env=MCP_APP_IDENTIFIER={}", identifier); + } + } } diff --git a/src-tauri/src/commands/ai/tools/entity_resolve.rs b/src-tauri/src/commands/ai/tools/entity_resolve.rs new file mode 100644 index 0000000..c709b57 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/entity_resolve.rs @@ -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, + tool_name: &str, + args: &serde_json::Value, +) -> anyhow::Result { + // 映射表未登记该工具 → 原样透传(零侵入) + 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, + tool_name: &str, + key: &str, + value: &str, +) -> anyhow::Result { + 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, 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 + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94acc77..04e3e94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,9 @@ mod commands; mod state; +// Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例 +mod mcp; +mod tray; use tauri::{Emitter, Listener, Manager}; @@ -51,6 +54,10 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) + // Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口) + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + tray::show_main(app); + })) .setup(|app| { // 数据库放在系统应用数据目录 下(dev/release 拆分): // Dev 模式 devflow-dev.db(可随意改动/清空),Build 模式 devflow.db(长期保留真实运行数据) @@ -71,6 +78,23 @@ pub fn run() { let session_for_handshake = app_state.ai_session.clone(); app.manage(app_state); + // ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ── + // 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。 + // mcp_get_status IPC 经 State> 读取,spawn 内写锁同步。 + let mcp_status = std::sync::Mutex::new(mcp::McpStatus { + running: false, + port: mcp::mcp_port(), + }); + app.manage(mcp_status); + // 2) 启动 HTTP MCP server(复用 AppState.db,与 GUI 共享同一连接)。 + // db 经 app.state::() 取 Arc clone(manage 已注入,setup 后续可用)。 + let db_for_mcp = app.state::().db.clone(); + mcp::spawn_mcp_http(app.handle(), db_for_mcp); + // 3) 创建托盘(关窗口不退出,托盘「退出」才真正结束进程)。失败非阻断。 + if let Err(e) = tray::setup_tray(app.handle()) { + tracing::error!("[tray] 托盘创建失败(非阻断): {}", e); + } + // B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态 // 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次), // 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。 @@ -475,7 +499,16 @@ pub fn run() { commands::settings::delete_template, // CI 状态 commands::ci_status::get_commit_status, + // Phase3: 内嵌 HTTP MCP server 状态查询 + crate::mcp::mcp_get_status, ]) + // Phase3: 关窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); // 拦截真正关闭 + let _ = window.hide(); // 隐藏到托盘 + } + }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6d6f610..adeb101 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,8 +7,7 @@ use std::process::ExitCode; fn main() -> ExitCode { // CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。 // - // `devflow mcp-server [--db ] [--read-only]` - // `devflow mcp-server --print-config` + // `devflow mcp-server [--db ] [--read-only] [--stdio] [--print-config]` // // 仅识别首个非程序名参数是否为 `mcp-server`;其余情况走 GUI。 // stdio 句柄由父进程(MCP 客户端)继承,无需额外配置。 @@ -24,10 +23,12 @@ fn main() -> ExitCode { /// /// - `--db `:指定 SQLite 路径(默认:系统 app_data_dir/devflow[-dev].db) /// - `--read-only`:仅暴露 list/get 工具 -/// - `--print-config`:输出 Claude Code / Cursor 的 MCP 配置 JSON 片段到 stdout 后退出 +/// - `--stdio`:显式声明 stdio 形态(默认即 stdio;headless/CI/GUI 未启动场景) +/// - `--print-config`:输出 MCP 配置到 stdout 后退出(默认 HTTP 形态,加 --stdio 输出 stdio command 形态) fn run_mcp_server(args: &[String]) -> ExitCode { let mut db_path: Option = None; let mut read_only = false; + let mut stdio_mode = false; let mut print_config = false; let mut i = 0; @@ -42,13 +43,15 @@ fn run_mcp_server(args: &[String]) -> ExitCode { db_path = Some(PathBuf::from(&args[i])); } "--read-only" => read_only = true, + "--stdio" => stdio_mode = true, "--print-config" => print_config = true, "-h" | "--help" => { - println!("devflow mcp-server [--db ] [--read-only] [--print-config]"); + println!("devflow mcp-server [--db ] [--read-only] [--stdio] [--print-config]"); println!(); - println!(" --db 指定 SQLite 数据库路径(默认:app_data_dir/devflow.db)"); + println!(" --db 指定 SQLite 数据库路径(默认:app_data_dir//devflow[-dev].db)"); println!(" --read-only 仅暴露 list/get 工具(拒绝 create/update/delete)"); - println!(" --print-config 输出 MCP 配置 JSON 片段到 stdout 后退出"); + println!(" --stdio 显式声明 stdio 形态(默认即 stdio;headless/CI/GUI 未启动场景)"); + println!(" --print-config 输出 MCP 配置(默认 HTTP,加 --stdio 输出 stdio command 形态)"); return ExitCode::SUCCESS; } other => { @@ -60,18 +63,36 @@ fn run_mcp_server(args: &[String]) -> ExitCode { } if print_config { - // 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态)。 - // Claude Code: claude mcp add devflow -- devflow mcp-server [--read-only] + // 说明性注释(非 JSON)打 stderr,保证 stdout 是纯 JSON 可直接被客户端消费。 + if !stdio_mode { + // 默认输出 HTTP 形态:桌面进程内嵌 HTTP MCP server + 托盘常驻, + // Claude Code 用 --transport http 直连同一进程。端口 18765 与 + // crates/df-mcp/src/server_http.rs 的 DEFAULT_MCP_PORT 对齐(本文件不 import,直接写死)。 + let json = r#"{"mcpServers":{"devflow":{"transport":"http","url":"http://127.0.0.1:18765/mcp"}}}"#; + println!("{json}"); + eprintln!("# Claude Code 接入(需先启动 DevFlow 桌面进程,托盘常驻):"); + eprintln!("# claude mcp add --transport http devflow http://127.0.0.1:18765/mcp"); + eprintln!("# stdio 备选(无 GUI 场景):"); + eprintln!("# devflow mcp-server --stdio --print-config"); + return ExitCode::SUCCESS; + } + + // 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态,显式带 --stdio)。 + // Claude Code: claude mcp add devflow -- devflow mcp-server --stdio [--read-only] // Cursor: 在 ~/.cursor/mcp.json 加 mcpServers.devflow let read_only_flag = if read_only { r#","--read-only""# } else { "" }; + // 用当前可执行文件绝对路径(command 需要能被客户端直接执行;硬编码 devflow + // 在 Windows 上是 DevFlow.exe,且 PATH 未必含安装目录)。 + let exe = std::env::current_exe() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| "devflow".to_owned()); let json = format!( - r#"{{"mcpServers":{{"devflow":{{"command":"devflow","args":["mcp-server"{read_only_flag}]}}}}}}"# + r#"{{"mcpServers":{{"devflow":{{"command":"{exe}","args":["mcp-server","--stdio"{read_only_flag}]}}}}}}"# ); println!("{json}"); - println!(); - println!("# Claude Code 接入:"); - println!("# claude mcp add devflow -- devflow mcp-server"); - println!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json"); + eprintln!("# Claude Code 接入(stdio 形态):"); + eprintln!("# claude mcp add devflow -- {exe} mcp-server --stdio"); + eprintln!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json"); return ExitCode::SUCCESS; } @@ -105,13 +126,12 @@ fn run_mcp_server(args: &[String]) -> ExitCode { } } -/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/devflow.db 或 dev-dev.db)。 +/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/{identifier}/devflow[-dev].db)。 /// -/// 复用与 lib.rs::run 一致的命名(debug 用 devflow-dev.db,release 用 devflow.db), -/// 保证 MCP server 与 GUI 读写同一文件。 +/// GUI 模式通过 Tauri 的 `app.path().app_data_dir()` 获取路径(含 identifier), +/// CLI 模式无 app handle,改用编译时注入的 MCP_APP_IDENTIFIER 拼出相同路径。 +/// 保证 MCP server 与 GUI 读写同一数据库,无需手动传 --db。 fn default_db_path() -> anyhow::Result { - // tauri 的 app_data_dir 需要 app handle,CLI 无 handle;用 dirs crate 或平台约定降级。 - // 这里用 std 环境变量推导(不引 dirs):优先 APPDATA/tav(Windows)、HOME( unix)。 let base = if cfg!(target_os = "windows") { std::env::var("APPDATA").map(PathBuf::from)? } else if cfg!(target_os = "macos") { @@ -121,8 +141,10 @@ fn default_db_path() -> anyhow::Result { let home = std::env::var("HOME").map(PathBuf::from)?; home.join(".local/share") }; - // Tauri app_data_dir 末段为包名/产品名(devflow)。dev 模式可能为 devflow-dev。 - let dir = base.join("devflow"); + // 从编译时环境变量 MCP_APP_IDENTIFIER 读取 Tauri identifier(如 top.1216.devflow), + // 与 tauri::path::PathResolver::app_data_dir 行为一致,拼出正确路径。 + let identifier = option_env!("MCP_APP_IDENTIFIER").unwrap_or("devflow"); + let dir = base.join(identifier); std::fs::create_dir_all(&dir)?; let name = if cfg!(debug_assertions) { "devflow-dev.db" diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs new file mode 100644 index 0000000..356af66 --- /dev/null +++ b/src-tauri/src/mcp.rs @@ -0,0 +1,99 @@ +//! 桌面进程内嵌 HTTP MCP server。 +//! +//! 复用 AppState.db 与 GUI 共享同一数据库连接,在 tauri 后台任务里常驻监听 +//! 127.0.0.1:/mcp,多 Claude 会话 HTTP 直连同一桌面进程读写 DevFlow 数据。 +//! +//! 运行时状态(McpStatus)以 `tauri::State>` 管理: +//! - setup 阶段 manage 初始 `{ running: false, port }` +//! - spawn 内 bind 成功/失败/异常退出时更新 running +//! - 前端经 `mcp_get_status` IPC 查询(Mutex 保证跨线程一致) +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tauri::{AppHandle, Manager, State}; + +use df_mcp::server_http::{self, McpHttpState}; +use df_storage::db::Database; + +/// 前端可查的 MCP 状态。 +#[derive(Clone, Serialize)] +pub struct McpStatus { + /// server 是否正在监听(bind 成功为 true;绑定失败/异常退出为 false) + pub running: bool, + /// 实际监听端口(env `DEVFLOW_MCP_PORT` 覆盖,默认 18765) + pub port: u16, +} + +/// managed state 别名:跨线程共享的 MCP 运行时状态。 +/// setup 中 `app.manage(Mutex::new(McpStatus { running: false, port }))` 注入, +/// spawn 内更新、`mcp_get_status` 读取。 +pub type McpStatusState = Mutex; + +/// 解析 MCP 监听端口:优先 env `DEVFLOW_MCP_PORT`,否则默认 18765。 +/// 供 setup(spawn 前 manage 初始状态)与 mcp_get_status(兜底)共用,保证一致。 +pub fn mcp_port() -> u16 { + std::env::var("DEVFLOW_MCP_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(server_http::DEFAULT_MCP_PORT) +} + +/// 在 tauri 后台任务里启动 HTTP MCP server(复用 AppState.db,与 GUI 共享连接)。 +/// +/// 前置条件:setup 中已 `app.manage(Mutex::new(McpStatus{...}))`(本函数内只更新不注入)。 +/// bind 成功/失败/异常退出都会同步更新 managed 状态,前端 mcp_get_status 可查实时值。 +pub fn spawn_mcp_http(app: &AppHandle, db: Arc) { + // 工具调用回调:复用现有 df-data-changed 机制,让 GUI 前端在 MCP 写库后自动刷新。 + // 非变更类工具(如 list/get)在 emit_data_changed 内部映射不到即 noop,零副作用。 + let app_for_cb = app.clone(); + let on_tool_call: Option> = Some(Arc::new(move |tool_name: &str| { + crate::commands::ai::audit::emit_data_changed(&app_for_cb, tool_name); + })); + let state = McpHttpState::new(db, false, on_tool_call); + let port = mcp_port(); + // 后台 task 持有 app clone,用于 bind 结果写回 managed 状态 + let app_for_status = app.clone(); + tauri::async_runtime::spawn(async move { + let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await { + Ok(l) => l, + Err(e) => { + tracing::error!(port, err = %e, "[mcp-http] 绑定端口失败, MCP server 不可用(可设 DEVFLOW_MCP_PORT 换端口)"); + set_mcp_running(&app_for_status, false); + return; + } + }; + // 绑定成功,更新状态为 running(供前端查询) + set_mcp_running(&app_for_status, true); + tracing::info!(port, "[mcp-http] MCP server listening on 127.0.0.1:{port}/mcp"); + if let Err(e) = server_http::serve_on(listener, state).await { + tracing::error!(err = %e, "[mcp-http] server 异常退出"); + set_mcp_running(&app_for_status, false); + } + }); +} + +/// 更新 managed McpStatus.running。state 未注入/try_state 失败/锁中毒时静默跳过 +/// (状态保持原值,非致命,防 panic 拖垮后台任务)。 +fn set_mcp_running(app: &AppHandle, running: bool) { + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.lock() { + guard.running = running; + } + } +} + +/// IPC command:前端查询 MCP server 状态。 +#[tauri::command] +pub fn mcp_get_status(state: State<'_, McpStatusState>) -> McpStatus { + match state.lock() { + Ok(guard) => McpStatus { + running: guard.running, + port: guard.port, + }, + // 锁中毒(理论上不应发生):降级返回静态配置,防 panic + Err(_) => McpStatus { + running: false, + port: mcp_port(), + }, + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..9a8401b --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,49 @@ +//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。 +//! +//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘 +//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 +//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 +use tauri::{ + AppHandle, Manager, + menu::{Menu, MenuItem}, + tray::TrayIconBuilder, +}; + +/// managed state 保活,防 GC 移除托盘图标。 +/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。 +#[allow(dead_code)] +pub struct TrayHandle(pub tauri::tray::TrayIcon); + +/// 创建托盘:图标 + 菜单(显示主窗口/退出)。 +pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { + let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 + let icon = app + .default_window_icon() + .cloned() + .expect("窗口图标缺失(tauri.conf.json icons 已配置)"); + let tray = TrayIconBuilder::with_id("devflow-tray") + .icon(icon) + .tooltip("DevFlow (MCP server running)") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id().as_ref() { + "show" => show_main(app), + "quit" => app.exit(0), // 真正退出 + _ => {} + }) + .build(app)?; + app.manage(TrayHandle(tray)); + Ok(()) +} + +/// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。 +pub(crate) fn show_main(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } +}