diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index cdc1b7b..ef4582d 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 成为每个项目的贴身导师。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-04-04 +updated: 2026-04-05 @@ -250,6 +250,13 @@ modules: progress: 100 position: [2000, 0] + - id: blueprint-buff + name: 蓝图 Buff + area: backend + status: done + progress: 100 + position: [2250, 0] + edges: # 前端依赖 - from: workspace-mgmt @@ -366,6 +373,17 @@ edges: - from: health-center to: project-mentor type: dependency + + # 蓝图 Buff + - from: blueprint-governance + to: blueprint-buff + type: dependency + - from: git-ops + to: blueprint-buff + type: dependency + - from: blueprint-buff + to: blueprint-view + type: related - from: blueprint-flywheel to: project-mentor type: dependency diff --git a/.blueprint/modules/blueprint-buff.md b/.blueprint/modules/blueprint-buff.md new file mode 100644 index 0000000..996c5b0 --- /dev/null +++ b/.blueprint/modules/blueprint-buff.md @@ -0,0 +1,57 @@ +--- +id: blueprint-buff +--- + +## 概述 + +炼境向被管理项目施加"蓝图 Buff"的能力层。Buff 注入后,git 提交自动触发任务状态同步(📋→🔵),炼境实时刷新蓝图视图。核心链路只依赖 git,与 AI 工具热插拔解耦,可批量复用到任意被管理项目。 + +## 决策记录 + +- 用 tokio 每5秒轮询 HEAD commit 而非写 git hook,原因:Windows 上 shell hook 执行依赖 Git Bash,路径复杂;轮询方式对项目零侵入且跨平台 +- git commit 只自动标 🔵(进行中),不自动标 ✅(完成);完成的语义需要验收,这是自动化的边界 +- Buff 同步后顺手调用 `get_blueprint()`,内部触发 `append_usage_snapshot()`,使飞轮数据与实际开发进度对齐 +- 使用文件路径匹配而非 commit message 解析,与 AI 工具/提交信息风格完全解耦 + +## 任务卡 + +### ✅ blueprint_buffs 数据表 +- status: done +- complexity: S +- files: src-tauri/src/db.rs +- acceptance: blueprint_buffs 表 migration 执行成功,字段包含 project_path/project_id/applied_at/last_sync_at/last_commit/status + +### ✅ Buff 生命周期 Rust 命令 +- status: done +- complexity: M +- files: src-tauri/src/commands/buff.rs, src-tauri/src/commands/mod.rs, src-tauri/src/lib.rs +- depends: blueprint-buff +- acceptance: apply_blueprint_buff/remove_blueprint_buff/get_buff_status/list_buffed_projects 四个命令可正常调用,apply 时同步调用 sync_blueprint_rules 注入规则 + +### ✅ git watcher + 任务匹配写回 + 飞轮快照 +- status: done +- complexity: M +- files: src-tauri/src/commands/buff.rs, src-tauri/Cargo.toml +- depends: blueprint-buff +- acceptance: git commit 后5秒内,files 字段命中的任务卡自动标记为 🔵 in_progress,调用 get_blueprint 追加飞轮快照,emit blueprint://changed 事件 + +### ✅ 前端 Buff 管理 UI +- status: done +- complexity: M +- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx, src/components/dashboard/ProjectCard.tsx +- depends: blueprint-buff +- acceptance: ProjectCard 已施加 Buff 时显示紫色⚡徽章,BlueprintModal GovernancePanel 有施加/撤销按钮,施加后展示 init prompt 供复制 + +### ✅ 前端实时刷新 +- status: done +- complexity: S +- files: src/components/dashboard/BlueprintModal.tsx +- depends: blueprint-buff +- acceptance: git commit 后已打开的蓝图视图自动重新加载,变更的任务卡显示"刚刚更新"标记并在3秒后淡出 + +### ✅ BlueprintView 跨项目切换 +- status: done +- complexity: M +- files: src/components/dashboard/BlueprintModal.tsx, src/components/dashboard/ProjectCard.tsx +- depends: blueprint-buff +- acceptance: 任意项目 ProjectCard 可打开该项目自己的蓝图视图,传入 projectId 支持 Buff 管理,炼境自身蓝图入口不受影响 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3d80f41..1901b55 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "dev-manager-tauri" -version = "0.1.11" +version = "0.1.12" dependencies = [ "axum", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 694bf62..4aa40f1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dev-manager-tauri" -version = "0.1.11" +version = "0.1.12" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -36,7 +36,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = "2" axum = "0.7" -tokio = { version = "1", features = ["net"] } +tokio = { version = "1", features = ["net", "time"] } ureq = { version = "2", features = ["json"] } open = "5" base64 = "0.22" diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 4cceec8..682465f 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -752,14 +752,17 @@ fn build_sync_prompt( 2. 发现目录中存在但蓝图缺失的功能模块 → 补充对应模块和任务卡\n\ 3. 将目录中已消失的功能标记为 abandoned 或删除对应模块\n\ 4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\ - 5. 更新 manifest.yaml 的 updated 日期为今天" + 5. 更新 manifest.yaml 的 updated 日期为今天\n\n\ + ⚠️ 状态保护规则:任务卡中 🔵 in_progress 状态由炼境根据 git 提交自动标记,同步时请保留,不要降回 📋 todo。" } else { // 正常 sync:有蓝图 + 有 git "1. 对照 git 提交记录,找出已完成但蓝图未标记的功能 → 更新对应任务卡为 ✅ done\n\ 2. 发现代码/目录中存在但蓝图缺失的功能模块 → 补充对应模块和任务卡\n\ 3. 将不再存在的功能标记为 abandoned 或删除\n\ 4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\ - 5. 更新 manifest.yaml 的 updated 日期为今天" + 5. 更新 manifest.yaml 的 updated 日期为今天\n\n\ + ⚠️ 状态保护规则:任务卡中 🔵 in_progress 状态由炼境根据 git 提交自动标记,代表该任务的关联文件已有实际改动。\ + 同步时请将其视为有效进度信号——可根据代码完成度升为 ✅ done,但无充分理由不要降回 📋 todo。" }; let output_hint = if no_blueprint { diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs new file mode 100644 index 0000000..f961ddf --- /dev/null +++ b/src-tauri/src/commands/buff.rs @@ -0,0 +1,349 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tauri::AppHandle; + +// ── 数据结构 ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BuffStatus { + pub project_path: String, + pub project_id: String, + pub applied_at: String, + pub last_sync_at: Option, + pub last_commit: Option, + pub status: String, +} + +// ── Tauri 命令 ─────────────────────────────────────────────────────────────── + +/// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB +#[tauri::command] +pub fn apply_blueprint_buff( + project_path: String, + project_id: String, +) -> Result { + let path = project_path.trim_end_matches(['/', '\\']).to_string(); + + // 注入蓝图规则(CONVENTIONS.md + CLAUDE.md) + crate::commands::blueprint::sync_blueprint_rules(path.clone()) + .map_err(|e| format!("注入蓝图规则失败: {e}"))?; + + // 写入 DB + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + conn.execute( + "INSERT OR REPLACE INTO blueprint_buffs + (project_path, project_id, applied_at, status) + VALUES (?1, ?2, datetime('now'), 'active')", + rusqlite::params![path, project_id], + ) + .map_err(|e| e.to_string())?; + + query_buff(&conn, &path) +} + +/// 撤销蓝图 Buff +#[tauri::command] +pub fn remove_blueprint_buff(project_path: String) -> Result<(), String> { + let path = project_path.trim_end_matches(['/', '\\']).to_string(); + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + conn.execute( + "DELETE FROM blueprint_buffs WHERE project_path = ?1", + rusqlite::params![path], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// 查询单个项目的 Buff 状态 +#[tauri::command] +pub fn get_buff_status(project_path: String) -> Result, String> { + let path = project_path.trim_end_matches(['/', '\\']).to_string(); + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + match query_buff(&conn, &path) { + Ok(s) => Ok(Some(s)), + Err(_) => Ok(None), + } +} + +/// 列出所有已施加 Buff 的项目 +#[tauri::command] +pub fn list_buffed_projects() -> Result, String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare( + "SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status + FROM blueprint_buffs WHERE status = 'active'", + ) + .map_err(|e| e.to_string())?; + + let items = stmt + .query_map([], |row| { + Ok(BuffStatus { + project_path: row.get(0)?, + project_id: row.get(1)?, + applied_at: row.get(2)?, + last_sync_at: row.get(3)?, + last_commit: row.get(4)?, + status: row.get(5)?, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(items) +} + +// ── 后台 git 轮询(T3)────────────────────────────────────────────────────── + +/// App 启动时调用,启动后台轮询任务(每 5 秒检查所有 buffed 项目的 git HEAD) +pub fn start_blueprint_watchers(app: AppHandle) { + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + poll_all_buffs(&app); + } + }); +} + +fn poll_all_buffs(app: &AppHandle) { + let paths = match list_active_paths() { + Ok(p) => p, + Err(_) => return, + }; + for path in paths { + let _ = check_project(app, &path); + } +} + +fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> { + let root = Path::new(project_path); + + // 获取当前 HEAD commit + let current_commit = match get_head_commit(root) { + Ok(c) => c, + Err(_) => return Ok(()), // 不是 git 仓库或无提交,跳过 + }; + + // 查询上次已处理的 commit + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + let last_commit: Option = conn + .query_row( + "SELECT last_commit FROM blueprint_buffs WHERE project_path = ?1", + rusqlite::params![project_path], + |row| row.get(0), + ) + .unwrap_or(None); + + // 没有变化,跳过 + if last_commit.as_deref() == Some(current_commit.as_str()) { + return Ok(()); + } + + // 获取变更文件列表 + let changed = get_changed_files(root, last_commit.as_deref())?; + + let mut synced = false; + if !changed.is_empty() { + let updated = update_task_statuses(root, &changed)?; + if updated > 0 { + // 触发飞轮快照(调用 get_blueprint 会触发内部 append_usage_snapshot) + let _ = crate::commands::blueprint::get_blueprint(project_path.to_string()); + + // 通知前端刷新 + use tauri::Emitter; + let _ = app.emit( + "blueprint://changed", + serde_json::json!({ "project_path": project_path }), + ); + synced = true; + } + } + + // 更新 DB:记录已处理的 commit + let _ = conn.execute( + "UPDATE blueprint_buffs + SET last_commit = ?1, last_sync_at = CASE WHEN ?3 THEN datetime('now') ELSE last_sync_at END + WHERE project_path = ?2", + rusqlite::params![current_commit, project_path, synced], + ); + + Ok(()) +} + +// ── 辅助:git 操作 ──────────────────────────────────────────────────────────── + +fn get_head_commit(root: &Path) -> Result { + let out = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .map_err(|e| e.to_string())?; + + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err("无法获取 HEAD".to_string()) + } +} + +fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result, String> { + // 有上次 commit:diff two commits;无:取最新一次 commit 的变更文件 + let args: Vec<&str> = if let Some(prev) = last_commit { + vec!["diff", "--name-only", prev, "HEAD"] + } else { + vec!["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"] + }; + + let out = std::process::Command::new("git") + .args(&args) + .current_dir(root) + .output() + .map_err(|e| e.to_string())?; + + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect()) + } else { + Ok(vec![]) + } +} + +// ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── + +fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result { + let modules_dir = root.join(".blueprint").join("modules"); + if !modules_dir.exists() { + return Ok(0); + } + + let mut total = 0; + for entry in std::fs::read_dir(&modules_dir) + .map_err(|e| e.to_string())? + .flatten() + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let (new_content, count) = mark_tasks_in_progress(&content, changed_files); + if count > 0 { + std::fs::write(&path, new_content).map_err(|e| e.to_string())?; + total += count; + } + } + Ok(total) +} + +/// 扫描 markdown 内容,将 files 字段命中 changed_files 的 📋 todo 任务标记为 🔵 in_progress +fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, usize) { + let mut lines: Vec = content.lines().map(|l| l.to_string()).collect(); + let mut updated = 0; + let mut i = 0; + + while i < lines.len() { + if !lines[i].starts_with("### 📋 ") { + i += 1; + continue; + } + + // 扫描该任务卡的属性行(到下一个 ## 或 ### 为止,最多 20 行) + let task_head = i; + let mut files_value: Option = None; + let mut status_idx: Option = None; + + let end = (i + 1 + 20).min(lines.len()); + for j in (i + 1)..end { + if j > i + 1 + && (lines[j].starts_with("### ") || lines[j].starts_with("## ")) + { + break; + } + let t = lines[j].trim().trim_start_matches("- "); + if let Some(v) = t.strip_prefix("files: ") { + files_value = Some(v.to_string()); + } + if t.starts_with("status: ") { + status_idx = Some(j); + } + } + + if let Some(fv) = files_value { + let task_files: Vec = fv + .split(',') + .map(|s| normalize_path(s.trim())) + .collect(); + + let hit = changed_files.iter().any(|cf| { + let ncf = normalize_path(cf); + task_files + .iter() + .any(|tf| tf == &ncf || ncf.ends_with(tf.as_str()) || tf.ends_with(ncf.as_str())) + }); + + if hit { + // 替换标题前缀 + lines[task_head] = lines[task_head].replacen("### 📋 ", "### 🔵 ", 1); + // 替换 status 行 + if let Some(si) = status_idx { + lines[si] = lines[si].replace("status: todo", "status: in_progress"); + } + updated += 1; + } + } + + i += 1; + } + + let mut result = lines.join("\n"); + if content.ends_with('\n') { + result.push('\n'); + } + (result, updated) +} + +fn normalize_path(p: &str) -> String { + p.trim_start_matches("./").replace('\\', "/").to_string() +} + +// ── 内部辅助 ────────────────────────────────────────────────────────────────── + +fn list_active_paths() -> Result, String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare("SELECT project_path FROM blueprint_buffs WHERE status = 'active'") + .map_err(|e| e.to_string())?; + + let paths = stmt + .query_map([], |row| row.get(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + Ok(paths) +} + +fn query_buff( + conn: &rusqlite::Connection, + path: &str, +) -> Result { + conn.query_row( + "SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status + FROM blueprint_buffs WHERE project_path = ?1", + rusqlite::params![path], + |row| { + Ok(BuffStatus { + project_path: row.get(0)?, + project_id: row.get(1)?, + applied_at: row.get(2)?, + last_sync_at: row.get(3)?, + last_commit: row.get(4)?, + status: row.get(5)?, + }) + }, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9d55c36..2364fbe 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,5 +17,6 @@ pub mod templates; pub mod tags; pub mod cicd; pub mod blueprint; +pub mod buff; pub mod canvas; pub mod mentor; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0a84250..f2404c1 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -412,6 +412,18 @@ fn migrate(conn: &rusqlite::Connection) { created_at TEXT DEFAULT (datetime('now', 'localtime')) ); ").expect("create project_notes table"); + + // 蓝图 Buff:记录哪些项目施加了 buff + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS blueprint_buffs ( + project_path TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')), + last_sync_at TEXT, + last_commit TEXT, + status TEXT NOT NULL DEFAULT 'active' + ); + ").expect("create blueprint_buffs table"); } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2101abe..3a0451a 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::{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::*}; +use commands::{activity::*, blueprint::*, buff::{apply_blueprint_buff, remove_blueprint_buff, get_buff_status, list_buffed_projects}, 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() { @@ -50,6 +50,9 @@ pub fn run() { tokio::task::spawn_blocking(run_startup_health_scan).await.ok(); }); + // ── 启动蓝图 Buff 后台轮询 ──────────────────────────── + commands::buff::start_blueprint_watchers(app.handle().clone()); + // ── 系统托盘 ────────────────────────────────────────────── let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?; @@ -235,6 +238,11 @@ pub fn run() { // canvas state get_canvas_state, save_canvas_state, + // blueprint buff + apply_blueprint_buff, + remove_blueprint_buff, + get_buff_status, + list_buffed_projects, // mentor get_mentor_context, get_project_notes, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9db8886..b65813c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "dev-manager-tauri", - "version": "0.1.11", + "version": "0.1.12", "identifier": "com.yangzhixiang.dev-manager-tauri", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx index e423573..44a137f 100644 --- a/src/components/dashboard/BlueprintModal.tsx +++ b/src/components/dashboard/BlueprintModal.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { listen } from "@tauri-apps/api/event"; import { ReactFlow, Background, @@ -21,6 +22,9 @@ import { getFlywheelStats, generateMuhePrompt, archiveProjectDocs, + applyBlueprintBuff, + removeBlueprintBuff, + getBuffStatus, type BlueprintData, type BlueprintModule, type BlueprintTask, @@ -30,11 +34,13 @@ import { type Project, type FlywheelStats, type StalledProject, + type BuffStatus, } from "../../lib/commands"; interface Props { projectName: string; projectPath: string; + projectId?: string; onClose: () => void; } @@ -236,14 +242,18 @@ function pickHandles( type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | { type: "governance" } | { type: "flywheel" } | null; -export function BlueprintModal({ projectName, projectPath, onClose }: Props) { +export function BlueprintModal({ projectName, projectPath, projectId, onClose }: Props) { const [sidePanel, setSidePanel] = useState(null); const [hoveredModuleId, setHoveredModuleId] = useState(null); const [pinnedModuleId, setPinnedModuleId] = useState(null); + const [recentlyUpdated, setRecentlyUpdated] = useState(false); + const recentlyUpdatedTimer = useRef | null>(null); // 实际高亮的模块:pin 优先,否则用 hover const activeModuleId = pinnedModuleId ?? hoveredModuleId; + const queryClient = useQueryClient(); + const { data: blueprint, isLoading, error } = useQuery({ queryKey: ["blueprint", projectPath], queryFn: () => getBlueprint(projectPath), @@ -255,6 +265,23 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) { enabled: !!projectPath, }); + // 监听 blueprint://changed 事件,路径匹配时刷新数据并高亮提示 + useEffect(() => { + let unlisten: (() => void) | undefined; + listen<{ project_path: string }>("blueprint://changed", (event) => { + const norm = (s: string) => s.replace(/\\/g, "/").replace(/\/$/, ""); + if (norm(event.payload.project_path) !== norm(projectPath)) return; + queryClient.invalidateQueries({ queryKey: ["blueprint", projectPath] }); + setRecentlyUpdated(true); + if (recentlyUpdatedTimer.current) clearTimeout(recentlyUpdatedTimer.current); + recentlyUpdatedTimer.current = setTimeout(() => setRecentlyUpdated(false), 3000); + }).then((fn) => { unlisten = fn; }); + return () => { + unlisten?.(); + if (recentlyUpdatedTimer.current) clearTimeout(recentlyUpdatedTimer.current); + }; + }, [projectPath, queryClient]); + const handleNodeClick = useCallback((mod: BlueprintModule) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id)); @@ -373,7 +400,14 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) { {/* 标题栏 */}
-

项目蓝图

+
+

项目蓝图

+ {recentlyUpdated && ( + + ⚡ 刚刚更新 + + )} +

{projectName} {blueprint?.manifest.updated && ( @@ -590,6 +624,7 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) { setSidePanel(null)} onOpenFlywheel={() => setSidePanel({ type: "flywheel" })} /> @@ -1000,11 +1035,13 @@ function NextActionsPanel({ function GovernancePanel({ projectPath, projectName, + projectId, onClose, onOpenFlywheel, }: { projectPath: string; projectName: string; + projectId?: string; onClose: () => void; onOpenFlywheel?: () => void; }) { @@ -1013,16 +1050,69 @@ function GovernancePanel({ queryFn: () => getBlueprintStatus(projectPath), }); + const { data: buffStatus, refetch: refetchBuff } = useQuery({ + queryKey: ["buff-status-governance", projectPath], + queryFn: () => getBuffStatus(projectPath), + staleTime: 5000, + }); + const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); const [syncError, setSyncError] = useState(null); + const [buffLoading, setBuffLoading] = useState(false); + const [buffError, setBuffError] = useState(null); + const [buffInitPrompt, setBuffInitPrompt] = useState(null); + const [buffInitCopied, setBuffInitCopied] = useState(false); + const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null); const [prompt, setPrompt] = useState(""); const [promptLoading, setPromptLoading] = useState(false); const [promptError, setPromptError] = useState(null); const [copied, setCopied] = useState(false); + const handleApplyBuff = async () => { + if (!projectId) return; + setBuffLoading(true); + setBuffError(null); + setBuffInitPrompt(null); + try { + await applyBlueprintBuff(projectPath, projectId); + refetchBuff(); + // 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化 + if (bpStatus?.status === "none") { + const p = await generateBlueprintPrompt(projectPath, "init").catch(() => null); + if (p) setBuffInitPrompt(p); + } + } catch (e) { + setBuffError(String(e)); + } finally { + setBuffLoading(false); + } + }; + + const handleRemoveBuff = async () => { + setBuffLoading(true); + setBuffError(null); + try { + await removeBlueprintBuff(projectPath); + refetchBuff(); + setBuffInitPrompt(null); + } catch (e) { + setBuffError(String(e)); + } finally { + setBuffLoading(false); + } + }; + + const handleCopyBuffPrompt = () => { + if (!buffInitPrompt) return; + navigator.clipboard.writeText(buffInitPrompt).then(() => { + setBuffInitCopied(true); + setTimeout(() => setBuffInitCopied(false), 2000); + }); + }; + const handleSyncRules = async () => { setSyncing(true); setSyncError(null); @@ -1210,6 +1300,68 @@ function GovernancePanel({

)} + {/* ── 蓝图 Buff ─────────────────────────────────────── */} +
+
+

蓝图 Buff

+ {buffStatus?.status === "active" && ( + ⚡ 已激活 + )} +
+

+ 施加 Buff 后,炼境将自动监听 git 提交,匹配任务文件并更新进度。 +

+ {buffStatus?.last_sync_at && ( +

上次同步:{buffStatus.last_sync_at}

+ )} + {buffError && ( +

{buffError}

+ )} + {buffStatus?.status === "active" ? ( + + ) : ( + + )} + {/* 已有蓝图时施加 Buff:直接激活,无需初始化 */} + {buffStatus?.status === "active" && bpStatus?.status !== "none" && !buffInitPrompt && ( +

+ 已激活。炼境将每 5 秒监听 git 提交,自动将命中任务文件的任务卡标记为 🔵 进行中。 +

+ )} + {/* 无蓝图时施加 Buff:展示 init prompt 供初始化 */} + {buffInitPrompt && ( +
+

将以下提示词交给 Claude 初始化蓝图:

+