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:
parent
23b109119a
commit
30d6823a1f
@ -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
|
||||
|
||||
57
.blueprint/modules/blueprint-buff.md
Normal file
57
.blueprint/modules/blueprint-buff.md
Normal 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
2
src-tauri/Cargo.lock
generated
@ -880,7 +880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dev-manager-tauri"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
349
src-tauri/src/commands/buff.rs
Normal file
349
src-tauri/src/commands/buff.rs
Normal 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> {
|
||||
// 有上次 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<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())
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<SidePanel>(null);
|
||||
const [hoveredModuleId, setHoveredModuleId] = 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
|
||||
const activeModuleId = pinnedModuleId ?? hoveredModuleId;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: blueprint, isLoading, error } = useQuery<BlueprintData | null>({
|
||||
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) {
|
||||
{/* 标题栏 */}
|
||||
<div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between shrink-0">
|
||||
<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">
|
||||
{projectName}
|
||||
{blueprint?.manifest.updated && (
|
||||
@ -590,6 +624,7 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
|
||||
<GovernancePanel
|
||||
projectPath={projectPath}
|
||||
projectName={projectName}
|
||||
projectId={projectId}
|
||||
onClose={() => 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<BuffStatus | null>({
|
||||
queryKey: ["buff-status-governance", projectPath],
|
||||
queryFn: () => getBuffStatus(projectPath),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResult | 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 [prompt, setPrompt] = useState("");
|
||||
const [promptLoading, setPromptLoading] = useState(false);
|
||||
const [promptError, setPromptError] = useState<string | null>(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({
|
||||
</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">
|
||||
<p className="text-[10px] text-gray-300 truncate">{projectName}</p>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
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 { PriorityBadge, StatusBadge } from "../ui/Badge";
|
||||
import { ProjectToolsModal } from "./ProjectToolsModal";
|
||||
@ -199,6 +199,14 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: buffStatus } = useQuery<BuffStatus | null>({
|
||||
queryKey: ["buffStatus", blueprintPath],
|
||||
queryFn: () => getBuffStatus(blueprintPath!),
|
||||
enabled: !!blueprintPath,
|
||||
staleTime: 10000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// PR 链接:从 repo_url 解析出 GitHub 路径
|
||||
const prUrl = (() => {
|
||||
if (!p.repo_url || !gitStat) return null;
|
||||
@ -380,6 +388,17 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
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>
|
||||
|
||||
@ -764,6 +783,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
<BlueprintModal
|
||||
projectName={p.name}
|
||||
projectPath={p.win_path || p.wsl_path || ""}
|
||||
projectId={p.id}
|
||||
onClose={() => setShowBlueprint(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1120,3 +1120,26 @@ export const getProjectNotes = (projectId: string) =>
|
||||
|
||||
export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
|
||||
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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user