diff --git a/src-tauri/src/commands/mentor.rs b/src-tauri/src/commands/mentor.rs index 55ce970..219eb71 100644 --- a/src-tauri/src/commands/mentor.rs +++ b/src-tauri/src/commands/mentor.rs @@ -1,6 +1,6 @@ use crate::db::pool; use rusqlite::params; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; // ── 返回结构 ────────────────────────────────────────────────────────────────── @@ -423,3 +423,311 @@ fn build_mentor_markdown( 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 = std::process::Command::new("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()); + std::process::Command::new("wsl") + .args(["-d", &distro, "--", "git", "-C", lp, "log", "-1", "--format=%ct"]) + .stderr(std::process::Stdio::null()) + .output() + .ok()? + } else { + let root = win_path?; + std::process::Command::new("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) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5600871..0a84250 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -401,6 +401,17 @@ fn migrate(conn: &rusqlite::Connection) { context TEXT ); ").expect("create runtime_logs table"); + + // AI 笔记(Claude 通过 MCP 或用户手动写入的分析结论) + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS project_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'mcp', + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + ").expect("create project_notes table"); } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7392ea..2101abe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ mod mcp_server; mod models; use git2; -use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*}; +use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -45,6 +45,11 @@ pub fn run() { let mcp_port = mcp_server::read_port(); tauri::async_runtime::spawn(mcp_server::start(mcp_port)); + // ── 启动健康扫描(异步,不阻塞窗口渲染)──────────────── + tauri::async_runtime::spawn(async { + tokio::task::spawn_blocking(run_startup_health_scan).await.ok(); + }); + // ── 系统托盘 ────────────────────────────────────────────── let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?; @@ -232,6 +237,10 @@ pub fn run() { save_canvas_state, // mentor get_mentor_context, + get_project_notes, + add_project_note, + get_group_mentor_report, + get_startup_alerts, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 423d725..7b00e5e 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -162,6 +162,24 @@ fn tools_list_result() -> Value { }, "required": ["project_id"] } + }, + { + "name": "append_project_note", + "description": "将 Claude 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" + }, + "content": { + "type": "string", + "description": "笔记内容,支持 Markdown。建议包含:核心观点、发现的问题、下一步建议。" + } + }, + "required": ["project_id", "content"] + } } ] }) @@ -200,6 +218,11 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); get_project_mentor_context(&gid, pid) } + "append_project_note" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); + append_project_note(&gid, pid, content) + } _ => Err(format!("未知工具: {}", tool_name)), }) .await; @@ -454,3 +477,28 @@ fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result Result { + if content.trim().is_empty() { + return Err("笔记内容不能为空".to_string()); + } + let conn = db::pool().get().map_err(|e| e.to_string())?; + // 校验 project_id 属于该 group + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", + [group_id, project_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n > 0) + .unwrap_or(false); + if !exists { + return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); + } + conn.execute( + "INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)", + rusqlite::params![project_id, content], + ) + .map_err(|e| e.to_string())?; + Ok("笔记已保存 ✅".to_string()) +} diff --git a/src/App.tsx b/src/App.tsx index aa82d8f..abfb4d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { HealthPage } from "./components/health/HealthPage"; import { Sidebar, type Page } from "./components/layout/Sidebar"; import { Toast } from "./components/ui/Toast"; import LoginPage from "./components/auth/LoginPage"; -import { githubGetAccount, githubLogout, type GitAccount } from "./lib/commands"; +import { githubGetAccount, githubLogout, getStartupAlerts, type GitAccount, type StartupAlert } from "./lib/commands"; import { useSpacesSync } from "./hooks/useSpacesSync"; import { useUIStore } from "./store/ui"; import { check as checkUpdate } from "@tauri-apps/plugin-updater"; @@ -18,6 +18,8 @@ import { check as checkUpdate } from "@tauri-apps/plugin-updater"; export default function App() { const [page, setPage] = useState("overview"); const [account, setAccount] = useState(undefined); + const [alerts, setAlerts] = useState([]); + const [alertsDismissed, setAlertsDismissed] = useState(false); const qc = useQueryClient(); const showToast = useUIStore((s) => s.showToast); @@ -36,6 +38,17 @@ export default function App() { .catch(() => {}); // 静默失败,不打扰用户 }, []); + // 启动健康扫描结果(延迟 2s 等后端扫描完成) + useEffect(() => { + if (account == null) return; + const timer = setTimeout(() => { + getStartupAlerts() + .then((list) => { if (list.length > 0) setAlerts(list); }) + .catch(() => {}); + }, 2000); + return () => clearTimeout(timer); + }, [account]); + const handleLogout = async () => { await githubLogout(); qc.clear(); @@ -64,13 +77,32 @@ export default function App() { onPageChange={setPage} /> -
- {page === "overview" && } - {page === "dashboard" && } - {page === "devtools" && } - {page === "proxy" && } - {page === "settings" && } - {page === "health" && } +
+ {alerts.length > 0 && !alertsDismissed && ( +
+ ⚠️ +
+ 项目需要关注: + + {alerts.slice(0, 3).map((a) => `${a.project_name}(${a.detail})`).join("、")} + {alerts.length > 3 && `,等 ${alerts.length - 3} 个项目`} + +
+ +
+ )} +
+ {page === "overview" && } + {page === "dashboard" && } + {page === "devtools" && } + {page === "proxy" && } + {page === "settings" && } + {page === "health" && } +
diff --git a/src/components/manage/ManagePage.tsx b/src/components/manage/ManagePage.tsx index 2f8948b..341f6d9 100644 --- a/src/components/manage/ManagePage.tsx +++ b/src/components/manage/ManagePage.tsx @@ -4,6 +4,7 @@ import { deleteProject, getGroups, getGroupMaps, getProjects, getSpaces, deleteGroup, addGroupMember, removeGroupMember, createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate, + getGroupMentorReport, type ProductGroup, type Project, type ProjectTemplate, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; @@ -211,6 +212,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) { const [groupModal, setGroupModal] = useState(undefined); const [memberTarget, setMemberTarget] = useState(null); const [delGroup, setDelGroup] = useState(null); + const [inspecting, setInspecting] = useState>(new Set()); + + const handleInspect = async (groupId: string) => { + setInspecting((prev) => new Set(prev).add(groupId)); + try { + const report = await getGroupMentorReport(groupId); + await navigator.clipboard.writeText(report); + showToast("已复制,可粘贴给 Claude 进行全组分析"); + } catch (e) { + showToast(`❌ 巡检失败: ${e}`); + } finally { + setInspecting((prev) => { const s = new Set(prev); s.delete(groupId); return s; }); + } + }; const { data: groups = [] } = useQuery({ queryKey: ["groups", spaceId], @@ -281,6 +296,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) { )}
+ diff --git a/src/components/mentor/MentorPanel.tsx b/src/components/mentor/MentorPanel.tsx index ecf4d49..552bcd0 100644 --- a/src/components/mentor/MentorPanel.tsx +++ b/src/components/mentor/MentorPanel.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { getMentorContext, type MentorContext } from "../../lib/commands"; +import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote } from "../../lib/commands"; interface Props { projectId: string; @@ -18,6 +18,13 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) { retry: false, }); + const { data: notes = [] } = useQuery({ + queryKey: ["projectNotes", projectId], + queryFn: () => getProjectNotes(projectId), + staleTime: 30000, + retry: false, + }); + const handleCopy = () => { if (!ctx) return; navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {}); @@ -143,6 +150,28 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) { )} + + {/* AI 笔记 */} +
+

AI 笔记

+ {notes.length === 0 ? ( +

暂无笔记。Claude 通过 MCP 分析项目后会在此留下洞察。

+ ) : ( +
    + {notes.map((note) => ( +
  • +
    + + {note.source === "mcp" ? "Claude · MCP" : "手动"} + + {note.created_at.slice(0, 16)} +
    +

    {note.content}

    +
  • + ))} +
+ )} +
)} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 6b32b2c..ddb9865 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1094,3 +1094,29 @@ export interface MentorContext { export const getMentorContext = (projectId: string) => invoke("get_mentor_context", { projectId }); + +export const getGroupMentorReport = (groupId: string) => + invoke("get_group_mentor_report", { groupId }); + +export interface StartupAlert { + project_name: string; + alert_type: string; + detail: string; +} + +export const getStartupAlerts = () => + invoke("get_startup_alerts"); + +export interface ProjectNote { + id: number; + project_id: string; + source: string; + content: string; + created_at: string; +} + +export const getProjectNotes = (projectId: string) => + invoke("get_project_notes", { projectId }); + +export const addProjectNote = (projectId: string, content: string, source: string = "manual") => + invoke("add_project_note", { projectId, content, source });