feat: 蓝图 Buff 系统 v0.1.12

炼境可向任意被管理项目施加「蓝图 Buff」,git 提交自动同步任务进度,与 AI 工具热插拔解耦。

- 后端:blueprint_buffs 数据表 + 生命周期命令(apply/remove/get/list)
- 后端:每 5 秒轮询 git HEAD,文件路径匹配任务卡 files 字段,自动标记 📋🔵
- 后端:sync 时 Buff 触发飞轮快照,emit blueprint://changed 事件
- 前端:ProjectCard 显示  Buff 激活徽章
- 前端:GovernancePanel 新增施加/撤销 Buff 按钮,无蓝图时附带 init prompt
- 前端:BlueprintModal 监听 blueprint://changed,自动刷新并显示「刚刚更新」提示
- 修复:sync prompt 新增 🔵 状态保护规则,防止 Claude 更新时降级炼境自动标记的进度
This commit is contained in:
lanrtop 2026-04-05 01:56:40 +09:00
parent 23b109119a
commit 30d6823a1f
13 changed files with 656 additions and 13 deletions

View File

@ -6,7 +6,7 @@ description: >
成为每个项目的贴身导师。炼境本身亦被纳入其中, 成为每个项目的贴身导师。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。 形成可自我进化的开发智能闭环。
iteration: 1 iteration: 1
updated: 2026-04-04 updated: 2026-04-05
@ -250,6 +250,13 @@ modules:
progress: 100 progress: 100
position: [2000, 0] position: [2000, 0]
- id: blueprint-buff
name: 蓝图 Buff
area: backend
status: done
progress: 100
position: [2250, 0]
edges: edges:
# 前端依赖 # 前端依赖
- from: workspace-mgmt - from: workspace-mgmt
@ -366,6 +373,17 @@ edges:
- from: health-center - from: health-center
to: project-mentor to: project-mentor
type: dependency 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 - from: blueprint-flywheel
to: project-mentor to: project-mentor
type: dependency type: dependency

View File

@ -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 管理,炼境自身蓝图入口不受影响

2
src-tauri/Cargo.lock generated
View File

@ -880,7 +880,7 @@ dependencies = [
[[package]] [[package]]
name = "dev-manager-tauri" name = "dev-manager-tauri"
version = "0.1.11" version = "0.1.12"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "dev-manager-tauri" name = "dev-manager-tauri"
version = "0.1.11" version = "0.1.12"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@ -36,7 +36,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
axum = "0.7" axum = "0.7"
tokio = { version = "1", features = ["net"] } tokio = { version = "1", features = ["net", "time"] }
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
open = "5" open = "5"
base64 = "0.22" base64 = "0.22"

View File

@ -752,14 +752,17 @@ fn build_sync_prompt(
2. \n\ 2. \n\
3. abandoned \n\ 3. abandoned \n\
4. status progress 使\n\ 4. status progress 使\n\
5. manifest.yaml updated " 5. manifest.yaml updated \n\n\
🔵 in_progress git 📋 todo"
} else { } else {
// 正常 sync有蓝图 + 有 git // 正常 sync有蓝图 + 有 git
"1. 对照 git 提交记录,找出已完成但蓝图未标记的功能 → 更新对应任务卡为 ✅ done\n\ "1. 对照 git 提交记录,找出已完成但蓝图未标记的功能 → 更新对应任务卡为 ✅ done\n\
2. / \n\ 2. / \n\
3. abandoned \n\ 3. abandoned \n\
4. status progress 使\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 { let output_hint = if no_blueprint {

View File

@ -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<String>,
pub last_commit: Option<String>,
pub status: String,
}
// ── Tauri 命令 ───────────────────────────────────────────────────────────────
/// 向项目施加蓝图 Buff注入 CONVENTIONS + CLAUDE.md写入 DB
#[tauri::command]
pub fn apply_blueprint_buff(
project_path: String,
project_id: String,
) -> Result<BuffStatus, String> {
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<Option<BuffStatus>, 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<Vec<BuffStatus>, 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<String> = 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<String, String> {
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<Vec<String>, String> {
// 有上次 commitdiff 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<usize, String> {
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<String> = 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<String> = None;
let mut status_idx: Option<usize> = 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<String> = 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<Vec<String>, 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<BuffStatus, String> {
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())
}

View File

@ -17,5 +17,6 @@ pub mod templates;
pub mod tags; pub mod tags;
pub mod cicd; pub mod cicd;
pub mod blueprint; pub mod blueprint;
pub mod buff;
pub mod canvas; pub mod canvas;
pub mod mentor; pub mod mentor;

View File

@ -412,6 +412,18 @@ fn migrate(conn: &rusqlite::Connection) {
created_at TEXT DEFAULT (datetime('now', 'localtime')) created_at TEXT DEFAULT (datetime('now', 'localtime'))
); );
").expect("create project_notes table"); ").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)] #[cfg(test)]

View File

@ -5,7 +5,7 @@ mod mcp_server;
mod models; mod models;
use git2; 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@ -50,6 +50,9 @@ pub fn run() {
tokio::task::spawn_blocking(run_startup_health_scan).await.ok(); 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 show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
@ -235,6 +238,11 @@ pub fn run() {
// canvas state // canvas state
get_canvas_state, get_canvas_state,
save_canvas_state, save_canvas_state,
// blueprint buff
apply_blueprint_buff,
remove_blueprint_buff,
get_buff_status,
list_buffed_projects,
// mentor // mentor
get_mentor_context, get_mentor_context,
get_project_notes, get_project_notes,

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "dev-manager-tauri", "productName": "dev-manager-tauri",
"version": "0.1.11", "version": "0.1.12",
"identifier": "com.yangzhixiang.dev-manager-tauri", "identifier": "com.yangzhixiang.dev-manager-tauri",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",

View File

@ -1,5 +1,6 @@
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event";
import { import {
ReactFlow, ReactFlow,
Background, Background,
@ -21,6 +22,9 @@ import {
getFlywheelStats, getFlywheelStats,
generateMuhePrompt, generateMuhePrompt,
archiveProjectDocs, archiveProjectDocs,
applyBlueprintBuff,
removeBlueprintBuff,
getBuffStatus,
type BlueprintData, type BlueprintData,
type BlueprintModule, type BlueprintModule,
type BlueprintTask, type BlueprintTask,
@ -30,11 +34,13 @@ import {
type Project, type Project,
type FlywheelStats, type FlywheelStats,
type StalledProject, type StalledProject,
type BuffStatus,
} from "../../lib/commands"; } from "../../lib/commands";
interface Props { interface Props {
projectName: string; projectName: string;
projectPath: string; projectPath: string;
projectId?: string;
onClose: () => void; onClose: () => void;
} }
@ -236,14 +242,18 @@ function pickHandles(
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | { type: "governance" } | { type: "flywheel" } | null; 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<SidePanel>(null); const [sidePanel, setSidePanel] = useState<SidePanel>(null);
const [hoveredModuleId, setHoveredModuleId] = useState<string | null>(null); const [hoveredModuleId, setHoveredModuleId] = useState<string | null>(null);
const [pinnedModuleId, setPinnedModuleId] = useState<string | null>(null); const [pinnedModuleId, setPinnedModuleId] = useState<string | null>(null);
const [recentlyUpdated, setRecentlyUpdated] = useState(false);
const recentlyUpdatedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 实际高亮的模块pin 优先,否则用 hover // 实际高亮的模块pin 优先,否则用 hover
const activeModuleId = pinnedModuleId ?? hoveredModuleId; const activeModuleId = pinnedModuleId ?? hoveredModuleId;
const queryClient = useQueryClient();
const { data: blueprint, isLoading, error } = useQuery<BlueprintData | null>({ const { data: blueprint, isLoading, error } = useQuery<BlueprintData | null>({
queryKey: ["blueprint", projectPath], queryKey: ["blueprint", projectPath],
queryFn: () => getBlueprint(projectPath), queryFn: () => getBlueprint(projectPath),
@ -255,6 +265,23 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
enabled: !!projectPath, 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) => { const handleNodeClick = useCallback((mod: BlueprintModule) => {
setSidePanel({ type: "module", module: mod }); setSidePanel({ type: "module", module: mod });
setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id)); setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id));
@ -373,7 +400,14 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
{/* 标题栏 */} {/* 标题栏 */}
<div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between shrink-0"> <div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between shrink-0">
<div> <div>
<h2 className="text-sm font-semibold text-gray-800"></h2> <div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-800"></h2>
{recentlyUpdated && (
<span className="text-[10px] text-purple-600 bg-purple-50 border border-purple-200 px-1.5 py-0.5 rounded-full animate-pulse">
</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5"> <p className="text-xs text-gray-400 mt-0.5">
{projectName} {projectName}
{blueprint?.manifest.updated && ( {blueprint?.manifest.updated && (
@ -590,6 +624,7 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
<GovernancePanel <GovernancePanel
projectPath={projectPath} projectPath={projectPath}
projectName={projectName} projectName={projectName}
projectId={projectId}
onClose={() => setSidePanel(null)} onClose={() => setSidePanel(null)}
onOpenFlywheel={() => setSidePanel({ type: "flywheel" })} onOpenFlywheel={() => setSidePanel({ type: "flywheel" })}
/> />
@ -1000,11 +1035,13 @@ function NextActionsPanel({
function GovernancePanel({ function GovernancePanel({
projectPath, projectPath,
projectName, projectName,
projectId,
onClose, onClose,
onOpenFlywheel, onOpenFlywheel,
}: { }: {
projectPath: string; projectPath: string;
projectName: string; projectName: string;
projectId?: string;
onClose: () => void; onClose: () => void;
onOpenFlywheel?: () => void; onOpenFlywheel?: () => void;
}) { }) {
@ -1013,16 +1050,69 @@ function GovernancePanel({
queryFn: () => getBlueprintStatus(projectPath), queryFn: () => getBlueprintStatus(projectPath),
}); });
const { data: buffStatus, refetch: refetchBuff } = useQuery<BuffStatus | null>({
queryKey: ["buff-status-governance", projectPath],
queryFn: () => getBuffStatus(projectPath),
staleTime: 5000,
});
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null); const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
const [syncError, setSyncError] = useState<string | null>(null); const [syncError, setSyncError] = useState<string | null>(null);
const [buffLoading, setBuffLoading] = useState(false);
const [buffError, setBuffError] = useState<string | null>(null);
const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null);
const [buffInitCopied, setBuffInitCopied] = useState(false);
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null); const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [promptLoading, setPromptLoading] = useState(false); const [promptLoading, setPromptLoading] = useState(false);
const [promptError, setPromptError] = useState<string | null>(null); const [promptError, setPromptError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); 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 () => { const handleSyncRules = async () => {
setSyncing(true); setSyncing(true);
setSyncError(null); setSyncError(null);
@ -1210,6 +1300,68 @@ function GovernancePanel({
</div> </div>
)} )}
{/* ── 蓝图 Buff ─────────────────────────────────────── */}
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider"> Buff</p>
{buffStatus?.status === "active" && (
<span className="text-[10px] text-purple-600 bg-purple-50 border border-purple-200 px-1.5 py-0.5 rounded-full"> </span>
)}
</div>
<p className="text-[11px] text-gray-500 leading-relaxed">
Buff git
</p>
{buffStatus?.last_sync_at && (
<p className="text-[10px] text-gray-400">{buffStatus.last_sync_at}</p>
)}
{buffError && (
<p className="text-[11px] text-red-600 font-mono break-all">{buffError}</p>
)}
{buffStatus?.status === "active" ? (
<button
onClick={handleRemoveBuff}
disabled={buffLoading}
className="w-full py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{buffLoading ? "处理中…" : "撤销 Buff"}
</button>
) : (
<button
onClick={handleApplyBuff}
disabled={buffLoading || !projectId}
title={!projectId ? "需要传入 projectId" : undefined}
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{buffLoading ? "施加中…" : "⚡ 施加 Buff"}
</button>
)}
{/* 已有蓝图时施加 Buff直接激活无需初始化 */}
{buffStatus?.status === "active" && bpStatus?.status !== "none" && !buffInitPrompt && (
<p className="text-[10px] text-purple-500 leading-relaxed">
5 git 🔵
</p>
)}
{/* 无蓝图时施加 Buff展示 init prompt 供初始化 */}
{buffInitPrompt && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] text-purple-600 font-semibold"> Claude </p>
<textarea
readOnly
value={buffInitPrompt}
rows={8}
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2 resize-none outline-none"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopyBuffPrompt}
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 transition-colors"
>
{buffInitCopied ? "已复制 ✓" : "复制提示词"}
</button>
</div>
)}
</div>
{/* 项目信息 */} {/* 项目信息 */}
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-300 truncate">{projectName}</p> <p className="text-[10px] text-gray-300 truncate">{projectName}</p>

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, type Project, type Space, type ActionsRun, type BlueprintStatus } from "../../lib/commands"; import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, getBuffStatus, type Project, type Space, type ActionsRun, type BlueprintStatus, type BuffStatus } from "../../lib/commands";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge"; import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { ProjectToolsModal } from "./ProjectToolsModal"; import { ProjectToolsModal } from "./ProjectToolsModal";
@ -199,6 +199,14 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
retry: false, retry: false,
}); });
const { data: buffStatus } = useQuery<BuffStatus | null>({
queryKey: ["buffStatus", blueprintPath],
queryFn: () => getBuffStatus(blueprintPath!),
enabled: !!blueprintPath,
staleTime: 10000,
retry: false,
});
// PR 链接:从 repo_url 解析出 GitHub 路径 // PR 链接:从 repo_url 解析出 GitHub 路径
const prUrl = (() => { const prUrl = (() => {
if (!p.repo_url || !gitStat) return null; if (!p.repo_url || !gitStat) return null;
@ -380,6 +388,17 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
onOpenViewer={() => setShowBlueprint(true)} onOpenViewer={() => setShowBlueprint(true)}
/> />
)} )}
{/* Buff 激活徽章 */}
{buffStatus?.status === "active" && (
<button
onClick={() => setShowBlueprint(true)}
title={`蓝图 Buff 已激活\n上次同步${buffStatus.last_sync_at ?? "未同步"}`}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors"
>
<span className="text-[10px]"></span>
<span className="text-[10px] text-purple-600">Buff</span>
</button>
)}
</div> </div>
</div> </div>
@ -764,6 +783,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
<BlueprintModal <BlueprintModal
projectName={p.name} projectName={p.name}
projectPath={p.win_path || p.wsl_path || ""} projectPath={p.win_path || p.wsl_path || ""}
projectId={p.id}
onClose={() => setShowBlueprint(false)} onClose={() => setShowBlueprint(false)}
/> />
)} )}

View File

@ -1120,3 +1120,26 @@ export const getProjectNotes = (projectId: string) =>
export const addProjectNote = (projectId: string, content: string, source: string = "manual") => export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
invoke<void>("add_project_note", { projectId, content, source }); invoke<void>("add_project_note", { projectId, content, source });
// ── Blueprint Buff ────────────────────────────────────────────────────────────
export interface BuffStatus {
project_path: string;
project_id: string;
applied_at: string;
last_sync_at?: string;
last_commit?: string;
status: string;
}
export const applyBlueprintBuff = (projectPath: string, projectId: string) =>
invoke<BuffStatus>("apply_blueprint_buff", { projectPath, projectId });
export const removeBlueprintBuff = (projectPath: string) =>
invoke<void>("remove_blueprint_buff", { projectPath });
export const getBuffStatus = (projectPath: string) =>
invoke<BuffStatus | null>("get_buff_status", { projectPath });
export const listBuffedProjects = () =>
invoke<BuffStatus[]>("list_buffed_projects");