use crate::db::pool; use rusqlite::params; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; // ── 返回结构 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct ProjectHealth { pub path_valid: bool, pub has_git: bool, /// git log --oneline -1 输出 pub last_commit: Option, /// 项目记录的技术栈 pub tech_stack: Option, } #[derive(Debug, Serialize)] pub struct BlueprintSummary { pub module_count: u32, pub done_count: u32, pub in_progress: u32, pub planned: u32, pub total_tasks: u32, pub done_tasks: u32, /// 可派发任务数 pub dispatchable: u32, } #[derive(Debug, Serialize)] pub struct RuntimeLogEntry { pub ts: String, pub category: String, pub level: String, pub message: String, } #[derive(Debug, Serialize)] pub struct MentorContext { pub project_id: String, pub project_name: String, pub health: ProjectHealth, pub blueprint_summary: Option, pub recent_errors: Vec, /// 格式化好的 Markdown,可直接复制给 Claude pub mentor_markdown: String, } // ── Tauri command ───────────────────────────────────────────────────────────── #[tauri::command] pub fn get_mentor_context(project_id: String) -> Result { let conn = pool().get().map_err(|e| e.to_string())?; // 1. 从 DB 读取项目基本信息 let row: rusqlite::Result<(String, Option, Option, Option, Option)> = conn .query_row( "SELECT pp.name, pw.win_path, pw.wsl_path, pw.platform, pp.tech_stack FROM project_workspaces pw JOIN project_profiles pp ON pp.id = pw.profile_id WHERE pw.id = ?1", params![&project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), ); let (project_name, win_path, wsl_path, platform, tech_stack) = row.map_err(|_| format!("项目 {} 不存在", project_id))?; // 2. 判断是否为 WSL 项目,并提前解析 distro(只解析一次,健康检查和蓝图共用) // - platform == "wsl":明确标记 // - 旧数据兼容:wsl_path 有值且 win_path 为空(迁移时 COALESCE 可能错填 'windows') let is_wsl = platform.as_deref() == Some("wsl") || (wsl_path.is_some() && (win_path.is_none() || win_path.as_deref() == Some(""))); // 2b. 若 wsl_path 存的是 UNC 路径(\\wsl.localhost\Distro\...), // 自动提取 distro 和对应的 Linux 路径;否则视为原始 Linux 路径 let (wsl_distro, wsl_linux_path): (Option, Option) = if is_wsl { match wsl_path.as_deref() { Some(p) if p.starts_with("\\\\wsl.localhost\\") || p.starts_with("\\\\wsl$\\") => { // UNC 格式:\\wsl.localhost\Ubuntu-22.04\home\user\project let (distro, linux) = unc_to_distro_and_linux(p); (Some(distro), Some(linux)) } Some(p) if !p.is_empty() => { // Linux 格式:/home/user/project let configured = read_wsl_distro(&conn); let distro = resolve_wsl_distro(&configured, p); (Some(distro), Some(p.to_string())) } _ => (None, None), } } else { (None, None) }; // 3. 本地健康检查 let (path_valid, has_git, last_commit) = match (is_wsl, &wsl_linux_path, &wsl_distro) { (true, Some(linux_path), Some(distro)) => { let pv = wsl_test_dir(distro, linux_path); let hg = pv && wsl_test_dir(distro, &format!("{}/.git", linux_path)); let lc = if hg { wsl_git_log(distro, linux_path) } else { None }; (pv, hg, lc) } (true, _, _) => (false, false, None), (false, _, _) => match win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new) { Some(root) => { let pv = root.exists(); let hg = root.join(".git").exists(); let lc = if hg { win_git_log(root) } else { None }; (pv, hg, lc) } None => (false, false, None), }, }; let health = ProjectHealth { path_valid, has_git, last_commit, tech_stack, }; // 4. 蓝图摘要(复用已解析的 wsl_distro / wsl_linux_path) let blueprint_summary = match (is_wsl, &wsl_linux_path, &wsl_distro) { (true, Some(linux_path), Some(distro)) => { read_blueprint_summary_wsl(linux_path, distro) } (true, _, _) => None, (false, _, _) => { win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new).and_then(read_blueprint_summary_win) } }; // 5. 近期错误日志 let recent_errors = read_recent_errors(&conn, &project_id); // 6. 生成 mentor_markdown let mentor_markdown = build_mentor_markdown( &project_id, &project_name, &health, &blueprint_summary, &recent_errors, ); Ok(MentorContext { project_id, project_name, health, blueprint_summary, recent_errors, mentor_markdown, }) } // ── WSL 辅助 ────────────────────────────────────────────────────────────────── /// 从 settings 表读取 wsl_distro,默认 "Ubuntu" fn read_wsl_distro(conn: &rusqlite::Connection) -> String { conn.query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0), ) .unwrap_or_else(|_| "Ubuntu".to_string()) } /// 用 `wsl -d -- test -d ` 检查 Linux 路径是否存在(目录) /// 比 Path::exists() 对 \\wsl.localhost\... UNC 路径更可靠 /// stderr 重定向到 null,避免 WSL 网络警告打印到控制台 fn wsl_test_dir(distro: &str, linux_path: &str) -> bool { crate::silent_cmd("wsl") .args(["-d", distro, "--", "test", "-d", linux_path]) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } /// 解析 WSL 项目实际使用的发行版名称: /// 1. 先试配置的 distro /// 2. 失败则枚举 `wsl -l -q` 找第一个能访问该 linux_path 的 distro fn resolve_wsl_distro(configured: &str, linux_path: &str) -> String { if wsl_test_dir(configured, linux_path) { return configured.to_string(); } // 枚举所有已安装 distro let distros = list_wsl_distros(); distros.into_iter() .find(|d| wsl_test_dir(d, linux_path)) .unwrap_or_else(|| configured.to_string()) } /// 列出系统所有已安装 WSL 发行版(解析 UTF-16 LE 输出) fn list_wsl_distros() -> Vec { let output = match crate::silent_cmd("wsl") .args(["-l", "-q"]) .stderr(std::process::Stdio::null()) .output() { Ok(o) => o, Err(_) => return vec![], }; let bytes = &output.stdout; let text = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { let words: Vec = bytes[2..] .chunks(2) .filter(|c| c.len() == 2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&words).to_string() } else if bytes.windows(2).any(|w| w[1] == 0) { let words: Vec = bytes .chunks(2) .filter(|c| c.len() == 2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&words).to_string() } else { String::from_utf8_lossy(bytes).to_string() }; text.lines() .map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\0').to_string()) .filter(|l| !l.is_empty()) .collect() } /// 在 WSL 内运行 git log,返回最近一条提交的单行摘要 fn wsl_git_log(distro: &str, linux_path: &str) -> Option { let output = crate::silent_cmd("wsl") .args(["-d", distro, "--", "git", "-C", linux_path, "log", "--oneline", "-1"]) .stderr(std::process::Stdio::null()) .output() .ok()?; if output.status.success() { let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); if s.is_empty() { None } else { Some(s) } } else { None } } /// 将 Linux 路径转为 \\wsl.localhost\\... UNC 路径 fn linux_to_unc(linux_path: &str, distro: &str) -> PathBuf { let inner = linux_path.trim_start_matches('/').replace('/', "\\"); PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner)) } /// UNC 路径 → (distro, linux_path) /// \\wsl.localhost\Ubuntu-22.04\home\user\proj → ("Ubuntu-22.04", "/home/user/proj") fn unc_to_distro_and_linux(unc: &str) -> (String, String) { let prefix = if unc.starts_with("\\\\wsl.localhost\\") { "\\\\wsl.localhost\\" } else { "\\\\wsl$\\" }; let rest = &unc[prefix.len()..]; let mut parts = rest.splitn(2, '\\'); let distro = parts.next().unwrap_or("Ubuntu").to_string(); let inner = parts.next().unwrap_or(""); let linux = format!("/{}", inner.replace('\\', "/")); (distro, linux) } // ── Windows 辅助 ────────────────────────────────────────────────────────────── fn win_git_log(root: &Path) -> Option { crate::silent_cmd("git") .args(["log", "--oneline", "-1"]) .current_dir(root) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()) } // ── 蓝图摘要 ────────────────────────────────────────────────────────────────── /// WSL 项目:用 wsl 命令判断 manifest 存在性,再通过 UNC 路径读取内容 fn read_blueprint_summary_wsl(linux_path: &str, distro: &str) -> Option { let manifest_linux = format!("{}/.blueprint/manifest.yaml", linux_path); // 用 wsl test -f 判断文件存在(不依赖 Path::exists()) let exists = crate::silent_cmd("wsl") .args(["-d", distro, "--", "test", "-f", &manifest_linux]) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); if !exists { return None; } // 文件存在,通过 UNC 路径读取(CreateFileW 可正常访问 \\wsl.localhost\...) let unc_root = linux_to_unc(linux_path, distro); parse_blueprint_summary(&unc_root) } /// Windows 项目:走常规 Path::exists() fn read_blueprint_summary_win(root: &Path) -> Option { if !root.join(".blueprint").join("manifest.yaml").exists() { return None; } parse_blueprint_summary(root) } fn parse_blueprint_summary(root: &Path) -> Option { let result = crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()); match result { Ok(Some(data)) => { let s = &data.stats; Some(BlueprintSummary { module_count: s.total_modules, done_count: s.done, in_progress: s.in_progress, planned: s.planned, total_tasks: s.total_tasks, done_tasks: s.tasks_done, dispatchable: s.dispatchable, }) } _ => { // manifest.yaml 存在但解析失败时,返回有蓝图但无法读取的状态 Some(BlueprintSummary { module_count: 0, done_count: 0, in_progress: 0, planned: 0, total_tasks: 0, done_tasks: 0, dispatchable: 0, }) } } } // ── 错误日志 ────────────────────────────────────────────────────────────────── fn read_recent_errors( conn: &rusqlite::Connection, project_id: &str, ) -> Vec { let sql = "SELECT ts, category, level, message FROM runtime_logs WHERE level IN ('error', 'warn') AND (json_extract(context, '$.project_id') = ?1 OR context IS NULL) ORDER BY id DESC LIMIT 20"; let result = conn.prepare(sql).and_then(|mut stmt| { stmt.query_map(params![project_id], |row| { Ok(RuntimeLogEntry { ts: row.get(0)?, category: row.get(1)?, level: row.get(2)?, message: row.get(3)?, }) }) .map(|iter| iter.filter_map(|r| r.ok()).collect::>()) }); result.unwrap_or_default() } // ── Markdown 生成 ───────────────────────────────────────────────────────────── fn build_mentor_markdown( project_id: &str, project_name: &str, health: &ProjectHealth, blueprint: &Option, errors: &[RuntimeLogEntry], ) -> String { let mut md = String::new(); md.push_str(&format!("# 项目导师报告:{}\n\n", project_name)); md.push_str(&format!("项目 ID:`{}`\n\n", project_id)); // ── 本地健康 ────────────────────────────────────────────────────────────── md.push_str("## 本地健康\n\n"); let path_icon = if health.path_valid { "✅" } else { "❌" }; let git_icon = if health.has_git { "✅" } else { "⚠️" }; md.push_str(&format!("- 本地路径:{} {}\n", path_icon, if health.path_valid { "有效" } else { "路径不存在" })); md.push_str(&format!("- Git 仓库:{} {}\n", git_icon, if health.has_git { "已初始化" } else { "未找到 .git 目录" })); if let Some(ref stack) = health.tech_stack { md.push_str(&format!("- 技术栈:{}\n", stack)); } if let Some(ref commit) = health.last_commit { md.push_str(&format!("- 最近提交:`{}`\n", commit)); } // ── 蓝图状态 ────────────────────────────────────────────────────────────── md.push_str("\n## 蓝图状态\n\n"); if let Some(ref bp) = blueprint { if bp.module_count == 0 { md.push_str("蓝图存在,但暂无模块数据。\n"); } else { let progress = (bp.done_count as f64 / bp.module_count as f64 * 100.0).round() as u32; md.push_str("| 维度 | 数值 |\n|------|------|\n"); md.push_str(&format!("| 模块总数 | {} |\n", bp.module_count)); md.push_str(&format!("| 已完成模块 | {} ({progress}%) |\n", bp.done_count)); md.push_str(&format!("| 进行中 | {} |\n", bp.in_progress)); md.push_str(&format!("| 已规划 | {} |\n", bp.planned)); md.push_str(&format!("| 任务总数 | {} |\n", bp.total_tasks)); md.push_str(&format!("| 已完成任务 | {} |\n", bp.done_tasks)); md.push_str(&format!("| 可派发任务 | {} |\n", bp.dispatchable)); } } else { md.push_str("该项目尚未接入蓝图(无 .blueprint/manifest.yaml)。\n"); } // ── 近期错误日志 ────────────────────────────────────────────────────────── md.push_str("\n## 近期错误日志(最多 20 条)\n\n"); if errors.is_empty() { md.push_str("无错误记录 ✅\n"); } else { for e in errors { md.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", e.ts, e.level, e.category, e.message)); } } md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。\n"); md } // ── 项目笔记 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize)] pub struct ProjectNote { pub id: i64, pub project_id: String, pub source: String, pub content: String, pub created_at: String, } #[tauri::command] pub fn get_project_notes(project_id: String) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT id, project_id, source, content, created_at FROM project_notes WHERE project_id = ?1 ORDER BY id DESC LIMIT 10", ) .map_err(|e| e.to_string())?; let notes = stmt .query_map(params![project_id], |row| { Ok(ProjectNote { id: row.get(0)?, project_id: row.get(1)?, source: row.get(2)?, content: row.get(3)?, created_at: row.get(4)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(notes) } #[tauri::command] pub fn add_project_note( project_id: String, content: String, source: String, ) -> Result<(), String> { if content.trim().is_empty() { return Err("笔记内容不能为空".to_string()); } let conn = pool().get().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO project_notes (project_id, source, content) VALUES (?1, ?2, ?3)", params![project_id, source, content], ) .map_err(|e| e.to_string())?; Ok(()) } // ── 启动健康扫描 ────────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct StartupAlert { pub project_name: String, pub alert_type: String, pub detail: String, } /// 应用启动时调用,扫描所有项目: /// - stale_git:超过 14 天无 git 提交 /// - blocked_tasks:蓝图中有 🔴 blocked 任务 /// 结果写入 runtime_logs(category="startup_alert"),同时返回告警列表供前端展示 pub fn run_startup_health_scan() { let conn = match pool().get() { Ok(c) => c, Err(_) => return, }; // 清除上次启动的 startup_alert 记录,避免堆积 let _ = conn.execute( "DELETE FROM runtime_logs WHERE category = 'startup_alert'", [], ); // 查询所有 workspace 的基本信息 let rows: Vec<(String, String, Option, Option, Option)> = { let mut stmt = match conn.prepare( "SELECT pw.id, pp.name, pw.win_path, pw.wsl_path, pw.platform FROM project_workspaces pw JOIN project_profiles pp ON pp.id = pw.profile_id", ) { Ok(s) => s, Err(_) => return, }; stmt.query_map([], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) }) .map(|iter| iter.filter_map(|r| r.ok()).collect()) .unwrap_or_default() }; for (project_id, project_name, win_path, wsl_path, platform) in rows { // 1. 检查 git 活跃度(超 14 天无提交) // 直接获取距今天数,None 表示路径不存在或无 git(跳过,不是"停滞") if let Some(days) = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn) { if days > 14 { // \x1f (ASCII Unit Separator) 作分隔符,避免项目名含 | 导致解析错位 crate::db::log_event( "startup_alert", "warn", &format!("{}\x1fstale_git\x1f{} 天未提交", project_name, days), Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)), ); } } // 2. 检查蓝图 blocked 任务 let blueprint_path_opt = if platform.as_deref() == Some("wsl") { wsl_path.as_deref().and_then(|lp| { let distro = conn .query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get::<_, String>(0)) .unwrap_or_else(|_| "Ubuntu".to_string()); let manifest_linux = format!("{}/.blueprint/manifest.yaml", lp); let exists = crate::silent_cmd("wsl") .args(["-d", &distro, "--", "test", "-f", &manifest_linux]) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); if exists { let inner = lp.trim_start_matches('/').replace('/', "\\"); Some(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}\\.blueprint", distro, inner))) } else { None } }) } else { win_path.as_deref().map(|p| std::path::Path::new(p).join(".blueprint")) }; if let Some(bp_dir) = blueprint_path_opt { let blocked_count = count_blocked_tasks(&bp_dir); if blocked_count > 0 { crate::db::log_event( "startup_alert", "warn", &format!("{}\x1fblocked_tasks\x1f{} 个任务阻塞", project_name, blocked_count), Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)), ); } } } } /// 获取项目最后一次 git 提交距今天数 fn get_days_since_last_commit( platform: Option<&str>, win_path: Option<&str>, wsl_path: Option<&str>, conn: &rusqlite::Connection, ) -> Option { let output = if platform == Some("wsl") { let lp = wsl_path?; let distro = conn .query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get::<_, String>(0)) .unwrap_or_else(|_| "Ubuntu".to_string()); crate::silent_cmd("wsl") .args(["-d", &distro, "--", "git", "-C", lp, "log", "-1", "--format=%ct"]) .stderr(std::process::Stdio::null()) .output() .ok()? } else { let root = win_path?; crate::silent_cmd("git") .args(["log", "-1", "--format=%ct"]) .current_dir(root) .output() .ok()? }; if !output.status.success() { return None; } let ts_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); let commit_ts: u64 = ts_str.parse().ok()?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .ok()? .as_secs(); Some((now.saturating_sub(commit_ts)) / 86400) } /// 统计 .blueprint/modules/ 下所有 .md 文件中 🔴 前缀任务卡的数量 fn count_blocked_tasks(blueprint_dir: &std::path::Path) -> usize { let modules_dir = blueprint_dir.join("modules"); let entries = match std::fs::read_dir(&modules_dir) { Ok(e) => e, Err(_) => return 0, }; let mut count = 0; for entry in entries.filter_map(|e| e.ok()) { if entry.path().extension().and_then(|e| e.to_str()) != Some("md") { continue; } if let Ok(content) = std::fs::read_to_string(entry.path()) { count += content.lines().filter(|l| l.starts_with("### 🔴")).count(); } } count } #[tauri::command] pub fn get_startup_alerts() -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT message FROM runtime_logs WHERE category = 'startup_alert' AND level = 'warn' ORDER BY id DESC LIMIT 50", ) .map_err(|e| e.to_string())?; let alerts = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .filter_map(|msg| { // 消息格式:"{project_name}\x1f{alert_type}\x1f{detail}" // 使用 ASCII Unit Separator 避免项目名含 | 导致解析错位 let parts: Vec<&str> = msg.splitn(3, '\x1f').collect(); if parts.len() == 3 { Some(StartupAlert { project_name: parts[0].to_string(), alert_type: parts[1].to_string(), detail: parts[2].to_string(), }) } else { None } }) .collect(); Ok(alerts) } // ── 全组巡检 ────────────────────────────────────────────────────────────────── #[tauri::command] pub fn get_group_mentor_report(group_id: String) -> Result { let conn = pool().get().map_err(|e| e.to_string())?; // 查询该产品组所有成员项目 let mut stmt = conn .prepare( "SELECT pw.id, pp.name FROM project_group_map pgm JOIN project_workspaces pw ON pw.id = pgm.project_id JOIN project_profiles pp ON pp.id = pw.profile_id WHERE pgm.group_id = ?1 ORDER BY pp.name", ) .map_err(|e| e.to_string())?; let members: Vec<(String, String)> = stmt .query_map(params![group_id], |row| Ok((row.get(0)?, row.get(1)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if members.is_empty() { return Err("该产品组暂无成员项目".to_string()); } let mut report = format!( "# 全组巡检报告\n\n产品组 ID:`{}`\n共 {} 个项目\n\n---\n\n", group_id, members.len() ); for (project_id, project_name) in &members { report.push_str(&format!("## {}\n\n", project_name)); match get_mentor_context(project_id.clone()) { Ok(ctx) => { // 去掉一级标题,同时把 ## 章节降级为 ###,避免与项目名 ## 同级混淆 let body = ctx .mentor_markdown .lines() .skip_while(|l| l.starts_with("# ")) .map(|l| { if l.starts_with("## ") { format!("#{}", l) } else { l.to_string() } }) .collect::>() .join("\n"); report.push_str(body.trim_start()); } Err(e) => { report.push_str(&format!("_获取导师上下文失败:{}_\n", e)); } } report.push_str("\n\n---\n\n"); } report.push_str("请根据以上所有项目的信息,对这个产品组的整体状态给出综合分析和下一步建议。\n"); Ok(report) }