feat: add BlueprintPromptModal for blueprint management and sync functionality
- Implemented BlueprintPromptModal component to handle blueprint prompt generation and rule synchronization. - Added BlueprintStatusBadge to display the current status of blueprints in ProjectCard. - Integrated blueprint status fetching and synchronization in ProjectCard. - Enhanced SettingsPage with a new section for MCP service configuration and batch sync for blueprint rules. - Updated commands to include new blueprint-related functionalities and types. - Changed application name from "Dev Manager" to "炼境" in various components.
This commit is contained in:
parent
a2596413c5
commit
c598c9b05b
@ -1,5 +1,5 @@
|
||||
---
|
||||
version: 1.0.0
|
||||
version: 1.3.0
|
||||
updated: 2026-04-03
|
||||
source: dev-manager-tauri
|
||||
---
|
||||
@ -25,10 +25,12 @@ source: dev-manager-tauri
|
||||
| 事件 | 操作 |
|
||||
|------|------|
|
||||
| 用户确认了新需求 | 在 manifest.yaml 新增模块,创建 modules/<id>.md |
|
||||
| 完成了功能实现 | 更新模块 status → done,标记任务卡 ✅ |
|
||||
| **开始执行任务卡(信息1)** | **任务卡前缀改为 🔵,status → in_progress(第一行代码之前)** |
|
||||
| **完成了功能实现(信息2)** | **任务卡前缀改为 ✅,status → done(验收通过之后)** |
|
||||
| 拆分了大需求为子任务 | 在 modules/<id>.md 中添加任务卡 |
|
||||
| 调整了架构方向 | 更新 edges 关系,可能调整 area |
|
||||
| 放弃了某个方向 | 删除对应模块和文件,或标记 status: abandoned |
|
||||
| **重构已完成的模块(迭代)** | **manifest.iteration 递增,旧任务卡保留,新建替代任务卡,模块加 `## 迭代记录`** |
|
||||
|
||||
## 模块状态定义
|
||||
|
||||
@ -52,6 +54,8 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
|
||||
- depends: other-module-id
|
||||
- acceptance: 简要描述完成标准
|
||||
- blocked_reason: (仅 status: blocked 时填写)执行受阻的具体原因
|
||||
- iteration: 1 # 可选,省略时继承 manifest 顶层 iteration 值
|
||||
- replaces: 旧任务标题 # 可选,仅重构任务填写,说明本卡替代哪张旧卡
|
||||
|
||||
详细说明(可选)。
|
||||
```
|
||||
@ -60,8 +64,8 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
|
||||
|
||||
| 前缀 | status | 含义 |
|
||||
|------|--------|------|
|
||||
| ✅ | done | 已完成 |
|
||||
| 🔵 | in_progress | 进行中 |
|
||||
| ✅ | done | 已完成(信息1 + 信息2 均已写入) |
|
||||
| 🔵 | in_progress | 执行中或被中断(只有信息1,等待信息2) |
|
||||
| 📋 | todo | 可派发(信息充足) |
|
||||
| 💭 | concept | 构思中 |
|
||||
| 🔴 | blocked | 执行受阻 |
|
||||
@ -78,6 +82,15 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
|
||||
|
||||
## manifest.yaml 规范
|
||||
|
||||
### 顶层字段
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: 项目名称
|
||||
iteration: 1 # 当前迭代号(默认 1,重构已完成模块时手动递增)
|
||||
updated: YYYY-MM-DD
|
||||
```
|
||||
|
||||
### 模块字段
|
||||
|
||||
```yaml
|
||||
@ -109,7 +122,64 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
|
||||
|
||||
模块的 progress 由其任务卡的完成比例推算:
|
||||
- 无任务卡的模块:手动设置 progress
|
||||
- 有任务卡的模块:`完成数 / 总数 × 100`(取整)
|
||||
- 有任务卡的模块(单迭代):`完成数 / 总数 × 100`(取整)
|
||||
- 有任务卡的模块(多迭代):**只计当前 iteration 的任务卡**,旧迭代 done 任务不计入分母,避免历史完成率虚高
|
||||
|
||||
## 迭代管理
|
||||
|
||||
### 何时递增 iteration
|
||||
|
||||
**需要递增**(重构已完成的功能):
|
||||
- 一个已有 ✅ done 任务卡的模块需要重新设计或重写
|
||||
- 核心交互/架构方案被替换(不是在原有基础上扩展)
|
||||
|
||||
**不需要递增**(正常演进):
|
||||
- 在现有模块上添加新功能
|
||||
- 修复 bug
|
||||
- 新增模块
|
||||
|
||||
### 重构时的操作步骤
|
||||
|
||||
1. **manifest.yaml 顶层** `iteration` +1,更新 `updated` 日期
|
||||
2. **旧任务卡**:保留原位,在卡片末尾加一行注释说明被替代(不改 status、不删除):
|
||||
```markdown
|
||||
### ✅ 旧功能实现
|
||||
- status: done
|
||||
- iteration: 1
|
||||
...
|
||||
> ⚠️ 已被迭代 2「新功能实现」替代,仅作历史记录。
|
||||
```
|
||||
3. **新任务卡**:新建替代卡,标注 `iteration` 和 `replaces`:
|
||||
```markdown
|
||||
### 📋 新功能实现
|
||||
- status: todo
|
||||
- iteration: 2
|
||||
- replaces: 旧功能实现
|
||||
- complexity: M
|
||||
- files: ...
|
||||
- acceptance: ...
|
||||
```
|
||||
4. **模块文件**:追加 `## 迭代记录` 段落(见下方格式)
|
||||
5. **模块 status**:重置为 `in_progress`,progress 按当前迭代任务卡重新计算
|
||||
|
||||
### 迭代记录格式
|
||||
|
||||
模块文件末尾追加,每次迭代一条:
|
||||
|
||||
```markdown
|
||||
## 迭代记录
|
||||
|
||||
### 迭代 2(YYYY-MM-DD)
|
||||
- **原因**:React Flow 在 50+ 节点时性能卡顿,重构为虚拟化渲染方案
|
||||
- **替代**:「React Flow 节点图渲染(迭代1)」→「节点图虚拟化渲染」
|
||||
- **保留**:迭代1的任务卡作为历史记录,不删除
|
||||
```
|
||||
|
||||
### 梦核如何感知迭代
|
||||
|
||||
- usage.json 快照携带 `iteration` 字段(见飞轮外环章节)
|
||||
- 飞轮数据层按 iteration 分组计算完成率,迭代跳变(如 1→2 后完成率骤降)是正常信号
|
||||
- 梦核 prompt 会展示「完成率跳变项目」摘要,Opus 据此判断是主动重构还是项目失控
|
||||
|
||||
## 决策记录格式
|
||||
|
||||
@ -144,15 +214,32 @@ Claude 在每次对话中应遵循以下流程:
|
||||
| "开始做 XX" | 模块 status → in_progress |
|
||||
| "为什么选这个方案" | 记录到模块的 `## 决策记录` |
|
||||
|
||||
### 阶段 3:实现功能 — 标记完成或上报阻塞
|
||||
```
|
||||
成功 → 更新任务卡前缀为 ✅ → status: done
|
||||
所有任务卡完成 → 模块 status: done
|
||||
更新 manifest.yaml 的 updated 日期
|
||||
### 阶段 3:实现功能 — 两阶段执行锁
|
||||
|
||||
任务卡执行遵循**两阶段提交**:开始写第一行代码前加锁(信息1),全部完成后解锁(信息2)。只有信息1没有信息2,说明任务被中断。
|
||||
|
||||
失败且可自行解决 → 继续尝试
|
||||
失败且无法独立解决 → 标记 🔴 blocked + 填写 blocked_reason → 留给架构师处理
|
||||
```
|
||||
📋 todo
|
||||
↓ 【信息1:加锁】第一行代码改动之前,先更新任务卡
|
||||
🔵 in_progress
|
||||
↓ 执行代码改动
|
||||
↓
|
||||
├─ 成功 → 【信息2:解锁】更新任务卡前缀为 ✅ → status: done
|
||||
│ 所有任务卡完成 → 模块 status: done
|
||||
│ 更新 manifest.yaml 的 updated 日期
|
||||
│
|
||||
├─ 失败且可自行解决 → 继续尝试,保持 🔵
|
||||
│
|
||||
└─ 失败且无法独立解决 → 🔴 blocked + blocked_reason → 留给架构师处理
|
||||
```
|
||||
|
||||
#### 锁悬空时的续接规则
|
||||
|
||||
新会话读蓝图发现 🔵 任务卡(只有信息1,没有信息2)时:
|
||||
1. 检查任务卡的 `files` 字段,读取相关文件的**当前代码状态**
|
||||
2. 判断已完成什么、还缺什么
|
||||
3. 从断点继续执行,**不重头来过**
|
||||
4. 完成后补写 ✅(信息2),关锁
|
||||
|
||||
#### 架构师处理 blocked 任务
|
||||
当 Opus 读蓝图发现 🔴 blocked 任务时:
|
||||
@ -179,6 +266,74 @@ Claude 应关注这些信号来决定是否更新蓝图:
|
||||
- 执行模型遇到无法解决的问题 → 标记 🔴 blocked + blocked_reason
|
||||
- Opus 看到 blocked 任务 → 分析原因,拆/补/调后产出新任务卡
|
||||
|
||||
## 飞轮外环(跨项目数据反哺)
|
||||
|
||||
内环描述的是单项目的执行流程。外环负责**把多个项目的执行数据聚合起来,反哺 CONVENTIONS 本身**。
|
||||
|
||||
### 数据采集:usage.json 快照
|
||||
|
||||
每次 Claude 读取 `.blueprint/manifest.yaml`(通过炼境打开蓝图时触发),炼境自动向项目的 `.blueprint/usage.json` 追加一条当日快照:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_name": "my-project",
|
||||
"snapshots": [
|
||||
{
|
||||
"at": "2026-04-05T10:00:00Z",
|
||||
"conventions_version": "1.3.0",
|
||||
"iteration": 1,
|
||||
"modules": { "total": 22, "done": 18, "in_progress": 2, "planned": 1, "concept": 1, "blocked": 0, "stalled": [] },
|
||||
"tasks": { "total": 45, "done": 38, "in_progress": 3, "blocked": 0, "todo": 4, "dispatched": 3 },
|
||||
"blocked_reasons": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `dispatched` = 当前满足📋可派发条件的任务数(有 files + acceptance + complexity S/M)
|
||||
- 节流:同一 UTC 日期只写一条,避免多次打开导致数据膨胀
|
||||
- 文件存于项目本地,不上传服务器
|
||||
|
||||
### 数据聚合:飞轮指标
|
||||
|
||||
炼境跨项目读取所有 usage.json,按 `conventions_version` 分组,对每个版本队列计算 4 项指标:
|
||||
|
||||
| 指标 | 计算方式 | 含义 |
|
||||
|------|----------|------|
|
||||
| 可派发率 | dispatched / total tasks | 任务卡规划质量,越高说明任务定义越清晰 |
|
||||
| 任务完成率 | done / total tasks | 整体执行进度均值 |
|
||||
| 飞轮持续率 | 30天内有快照的项目比例 | 外环是否在持续运转 |
|
||||
| 阻塞密度 | blocked / total tasks | 系统性阻塞程度,越低越好 |
|
||||
|
||||
迭代感知:同一项目若 `iteration` 值增大,说明发生了主动重构。飞轮数据层对此类项目单独计算「当前迭代完成率」,与历史累计值区分展示,避免虚高。梦核 prompt 中标注「完成率跳变项目」供 Opus 分析是健康重构还是项目失控。
|
||||
|
||||
### 梦核分析:记忆巩固
|
||||
|
||||
炼境将聚合数据 + 当前 CONVENTIONS 全文格式化为提示词,发给 **Claude Opus** 做两件事:
|
||||
|
||||
- **固化**:哪些跨项目重复出现的阻塞模式、哪些有效实践,值得写进 CONVENTIONS
|
||||
- **遗忘**:哪些休眠项目(90天无快照)的 AI 文档可以归档,哪些 CONVENTIONS 规则已过时
|
||||
|
||||
### 闭环路径
|
||||
|
||||
```
|
||||
执行任务 → usage.json 快照
|
||||
↓
|
||||
炼境聚合指标
|
||||
↓
|
||||
梦核提示词 → Claude Opus 分析
|
||||
↓
|
||||
用户/维护者审核建议
|
||||
↓
|
||||
更新 CONVENTIONS.md 源码 + 版本号
|
||||
↓
|
||||
发布新版炼境
|
||||
↓
|
||||
治理面板 sync_blueprint_rules → 分发到各项目
|
||||
```
|
||||
|
||||
> **注意**:CONVENTIONS.md 编译进炼境二进制,更新需要修改炼境源码并发版,不支持运行时修改。这是有意为之的设计——保证规则版本可追溯,防止随意污染。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要删除已完成的任务卡,它们是项目历史的一部分
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
version: 1
|
||||
name: Dev Manager Tauri
|
||||
name: 炼境
|
||||
description: Git 多空间开发管理器,集成项目管理、Git 操作、CI/CD、开发工具链管理
|
||||
updated: 2026-04-03
|
||||
iteration: 1
|
||||
updated: 2026-04-04
|
||||
|
||||
|
||||
|
||||
areas:
|
||||
@ -83,8 +85,8 @@ modules:
|
||||
- id: blueprint-onboarding
|
||||
name: 蓝图接入引导
|
||||
area: frontend
|
||||
status: planned
|
||||
progress: 0
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1250, 0]
|
||||
|
||||
# ── 后端 ────────────────────────────────────────
|
||||
@ -140,15 +142,22 @@ modules:
|
||||
- id: blueprint-governance
|
||||
name: 蓝图治理
|
||||
area: backend
|
||||
status: planned
|
||||
progress: 0
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1250, 350]
|
||||
|
||||
- id: blueprint-feedback
|
||||
name: 蓝图反馈聚合
|
||||
- id: blueprint-flywheel
|
||||
name: 飞轮数据层
|
||||
area: backend
|
||||
status: concept
|
||||
progress: 0
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1000, 500]
|
||||
|
||||
- id: blueprint-feedback
|
||||
name: 梦核 · 记忆巩固
|
||||
area: backend
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1250, 500]
|
||||
|
||||
# ── 基础设施 ────────────────────────────────────
|
||||
@ -163,7 +172,7 @@ modules:
|
||||
name: 客户端自动更新
|
||||
area: infra
|
||||
status: done
|
||||
progress: 90
|
||||
progress: 100
|
||||
position: [250, 650]
|
||||
|
||||
- id: wsl-proxy
|
||||
@ -187,6 +196,34 @@ modules:
|
||||
progress: 100
|
||||
position: [750, 650]
|
||||
|
||||
- id: tray-service
|
||||
name: 系统托盘常驻
|
||||
area: infra
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1000, 650]
|
||||
|
||||
- id: mcp-server
|
||||
name: 内嵌 MCP Server
|
||||
area: backend
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1500, 350]
|
||||
|
||||
- id: mcp-config-inject
|
||||
name: MCP 配置注入
|
||||
area: backend
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1500, 500]
|
||||
|
||||
- id: product-group-mcp
|
||||
name: 产品组 MCP 设置
|
||||
area: frontend
|
||||
status: done
|
||||
progress: 100
|
||||
position: [1500, 0]
|
||||
|
||||
edges:
|
||||
# 前端依赖
|
||||
- from: workspace-mgmt
|
||||
@ -235,14 +272,21 @@ edges:
|
||||
- from: blueprint-reader
|
||||
to: blueprint-governance
|
||||
type: related
|
||||
- from: blueprint-view
|
||||
to: blueprint-onboarding
|
||||
type: dependency
|
||||
- from: blueprint-governance
|
||||
to: blueprint-feedback
|
||||
type: related
|
||||
- from: blueprint-onboarding
|
||||
to: blueprint-view
|
||||
type: dependency
|
||||
# 外环:飞轮数据层 → 梦核 → 治理分发
|
||||
- from: blueprint-reader
|
||||
to: blueprint-flywheel
|
||||
type: dependency
|
||||
- from: blueprint-flywheel
|
||||
to: blueprint-feedback
|
||||
type: dependency
|
||||
- from: blueprint-feedback
|
||||
to: blueprint-governance
|
||||
type: dependency
|
||||
- from: blueprint-view
|
||||
to: blueprint-flywheel
|
||||
type: related
|
||||
|
||||
# 基础设施关联
|
||||
@ -258,3 +302,17 @@ edges:
|
||||
- from: auto-update
|
||||
to: settings-page
|
||||
type: related
|
||||
|
||||
# 产品组 MCP 飞轮
|
||||
- from: tray-service
|
||||
to: mcp-server
|
||||
type: dependency
|
||||
- from: mcp-server
|
||||
to: mcp-config-inject
|
||||
type: dependency
|
||||
- from: mcp-config-inject
|
||||
to: product-group-mcp
|
||||
type: dependency
|
||||
- from: product-group-mcp
|
||||
to: settings-page
|
||||
type: related
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: auto-update
|
||||
name: 客户端自动更新
|
||||
status: done
|
||||
progress: 90
|
||||
progress: 100
|
||||
---
|
||||
|
||||
## 概述
|
||||
@ -35,6 +35,6 @@ progress: 90
|
||||
|
||||
updater:default + process:default + http 域名放行。
|
||||
|
||||
### 💭 自动检查更新
|
||||
- status: concept
|
||||
### ✅ 自动检查更新
|
||||
- status: done
|
||||
- notes: 应用启动时自动检查,发现新版本后在状态栏提示,而非需要手动进入设置页。
|
||||
|
||||
@ -1,29 +1,64 @@
|
||||
---
|
||||
id: blueprint-feedback
|
||||
name: 蓝图反馈聚合
|
||||
status: concept
|
||||
progress: 0
|
||||
name: 梦核 · 记忆巩固
|
||||
status: done
|
||||
progress: 100
|
||||
---
|
||||
|
||||
## 概述
|
||||
建立跨项目蓝图反馈收集和分析机制。各项目 Claude 执行时将遇到的问题、格式缺口、改进建议写入 `.blueprint/FEEDBACK.md`,dev-manager-tauri 扫描聚合后呈现,供 Opus 分析并优化 master CONVENTIONS.md,形成飞轮闭环。
|
||||
炼境的记忆巩固层。类比人类睡眠时大脑对记忆的主动处理——保留重要的(固化进 CONVENTIONS),清理无用的(归档休眠文档)。
|
||||
|
||||
## 决策记录
|
||||
梦核不是算法,是一条精心设计的提示词。炼境负责收集和格式化数据(飞轮数据层),Claude 负责做判断:哪些跨项目重复出现的阻塞模式值得写进 CONVENTIONS,哪些长期休眠的文档可以归档。用户确认后,治理模块执行分发。
|
||||
|
||||
- FEEDBACK.md 为纯 Markdown,按条目写入,每条含 type / reason / suggestion 字段
|
||||
- 反馈类型:blocked(执行受阻)/ convention_gap(规则不够用)/ format_issue(格式问题)/ improvement(改进建议)
|
||||
- 聚合视图只读,不提供编辑功能,Claude 才是修改 CONVENTIONS.md 的执行者
|
||||
```
|
||||
[飞轮数据层] 聚合跨项目数据
|
||||
↓
|
||||
[梦核提示词] 喂给 Claude Opus 做判断
|
||||
↓
|
||||
固化 ──────────────────── 遗忘
|
||||
↓ ↓
|
||||
CONVENTIONS 改进草稿 归档候选列表
|
||||
↓ ↓
|
||||
[治理模块] 分发 用户确认 → 归档操作
|
||||
```
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 💭 FEEDBACK.md 格式约定
|
||||
- status: concept
|
||||
- notes: 需要在 CONVENTIONS.md 中加入 FEEDBACK.md 的写入规则和格式规范,确保各项目 Claude 写入格式一致
|
||||
### ✅ 梦核分析入口(蓝图弹窗)
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
|
||||
- depends: blueprint-flywheel
|
||||
- acceptance: 治理面板新增「梦核分析」区块,点击"♻️ 打开飞轮面板"跳转到飞轮面板;飞轮面板生成梦核提示词(可复制);引导文字说明正确流程:「复制 → 粘贴给 Claude Opus → 将建议反馈给炼境维护者 → 更新 CONVENTIONS.md 并发布新版本」(梦核不自动写入,需手动修改源码发版)
|
||||
|
||||
### 💭 get_all_blueprint_feedbacks command
|
||||
- status: concept
|
||||
- notes: 扫描所有已注册项目的 .blueprint/FEEDBACK.md,解析条目,按 type 分类返回
|
||||
### ✅ 归档候选展示
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/components/dashboard/BlueprintModal.tsx
|
||||
- depends: blueprint-flywheel
|
||||
- acceptance: 飞轮面板展示两类归档候选:① 飞轮停转项目(usage.json 最新 at 距今超过 60 天,显示最后活跃日期)② AI文档休眠项目(usage.json 最新 at 距今超过 90 天,推断 Claude 长期未读该项目的 AI 文档);每条候选显示项目名 + 最后活跃日期 + 「归档」按钮
|
||||
|
||||
### 💭 跨项目反馈聚合视图
|
||||
### ✅ 归档 command
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- acceptance: archive_project_docs command;接收项目路径;将 .blueprint/modules/*.md 复制到 .blueprint/archive/YYYY-MM-DD-HHmmss/ 后删除原文件;写 MANIFEST.txt 记录归档信息;返回归档摘要(archived_files + archive_dir)
|
||||
|
||||
### 💭 恢复 command
|
||||
- status: concept
|
||||
- notes: 在蓝图界面或设置页展示:高频 blocked 原因、重复出现的 convention_gap、跨项目 suggestion 列表;提供「复制给 Opus 分析」按钮,生成汇总报告
|
||||
- notes: 读取 .blueprint/archive/*/MANIFEST.txt,将文件还原到原路径,支持选择性恢复,覆盖前需用户确认
|
||||
|
||||
### ✅ 梦核提示词增加停滞模块数据
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/commands/blueprint.rs, src/lib/commands.ts
|
||||
- acceptance: get_flywheel_stats 收集所有项目最新快照的 modules.stalled,聚合为 stalled_modules: Vec<String>(格式 "project/module_id");FlywheelStats 新增 stalled_modules 字段;梦核提示词新增「停滞模块」摘要段;同步实现迭代跳变检测(iteration_jumps)并补入提示词
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 梦核的核心是提示词工程而非算法,判断质量随 Claude 模型升级自动变强,无需维护打分公式
|
||||
- 固化输出(CONVENTIONS 改进)由用户确认后交 governance 执行,不自动写入,保留人工节点
|
||||
- 遗忘操作(归档)提供撤销能力(MANIFEST.txt + 恢复 command),降低误操作风险
|
||||
- AI 文档休眠判断基于 usage.json 中 get_blueprint 的调用记录,间接反映 Claude 是否在读这些文档
|
||||
- 命名选「梦核」:梦境处理记忆(非主动学习),核心是巩固而非监控
|
||||
- 归档功能直接操作 .blueprint/modules/,用户在飞轮面板触发,清空休眠项目的模块 md 文件
|
||||
|
||||
55
.blueprint/modules/blueprint-flywheel.md
Normal file
55
.blueprint/modules/blueprint-flywheel.md
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
id: blueprint-flywheel
|
||||
name: 飞轮数据层
|
||||
status: done
|
||||
progress: 100
|
||||
---
|
||||
|
||||
## 概述
|
||||
外环数据采集与聚合层。在各项目的 .blueprint/usage.json 中记录飞轮运转快照(任务完成率、阻塞密度、conventions 版本),炼境跨项目聚合后计算健康指标,并格式化为梦核分析提示词的输入数据。
|
||||
|
||||
代码只处理确定性的机械工作(采集、计算、格式化),判断交给 Claude(梦核)。
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ usage.json 埋点写入
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
- acceptance: get_blueprint command 执行后,向项目 .blueprint/usage.json 追加一条 snapshot;schema:{ project_name, at, conventions_version, iteration, modules: {total,done,in_progress,planned,concept,blocked,stalled:string[]}, tasks: {total,done,in_progress,blocked,todo,dispatched}, blocked_reasons: string[] };iteration 从 manifest.yaml 顶层 iteration 字段读取,失败写 1;节流:同一 UTC 日期同一项目只追加一条;conventions_version 从 .blueprint/CONVENTIONS.md frontmatter 读取,失败写 "unknown";usage.json 不存在时自动创建为 { "project_name": "...", "snapshots": [] }
|
||||
|
||||
### ✅ get_flywheel_stats command
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- depends: blueprint-reader
|
||||
- acceptance: 接收项目路径数组(由前端从 store 传入,与 sync_blueprint_rules 同模式);读取每个项目的 .blueprint/usage.json;按 conventions_version 分组,对每组计算 4 项指标:可派发率(dispatched/total tasks)、任务完成率(done/total tasks)、飞轮持续率(30天内有快照的项目比例)、阻塞密度(blocked/total tasks);跨项目聚合 blocked_reasons TOP10;检测停转项目(60天无快照)和 AI文档休眠项目(90天无快照),均携带 last_active 日期;返回结构化 FlywheelStats
|
||||
|
||||
### ✅ get_flywheel_stats 支持迭代感知
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs, src/lib/commands.ts
|
||||
- acceptance: usage.json snapshot 写入 iteration(读 manifest.yaml 顶层字段,失败写 1);BlueprintManifest struct 新增 iteration 字段;get_flywheel_stats 扫描每个项目所有快照,检测 iteration 跳变,返回 iteration_jumps: Vec<IterationJump>;同时收集最新快照中 modules.stalled 列表,返回 stalled_modules: Vec<String>(格式 "project/module_id");梦核 prompt 补充「迭代跳变项目」和「停滞模块」两段摘要;commands.ts 新增 IterationJump 接口,FlywheelStats 补两字段
|
||||
|
||||
### ✅ 梦核提示词生成 command
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- depends: blueprint-reader
|
||||
- acceptance: generate_muhe_prompt command,接收 FlywheelStats(由前端先调 get_flywheel_stats 获取后传入)+ 读取 master CONVENTIONS.md 内容,生成完整梦核分析提示词字符串;提示词包含:聚合数据摘要、TOP blocked_reasons、CONVENTIONS 全文、固化/遗忘双任务指令;注意:本任务须在 get_flywheel_stats 实现后执行
|
||||
|
||||
### ✅ 飞轮健康面板
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
|
||||
- depends: blueprint-view
|
||||
- acceptance: 蓝图弹窗新增「飞轮」侧边面板;展示:各 conventions 版本队列的指标对比表(可派发率 / 任务完成率 / 飞轮持续率 / 阻塞密度)、跨项目 blocked_reasons TOP10、飞轮停转项目(60–90天,仅展示未进入休眠列表的)、AI文档休眠项目(90天+,含归档按钮+二次确认弹窗)、迭代跳变项目(iteration 递增,展示完成率跳变,⚠️标注大幅下降)、停滞模块(in_progress 但无可推进任务,格式 project/module_id);「生成梦核提示词」按钮调用 generate_muhe_prompt,展示结果供复制
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 代码只做机械工作(采集、聚合、格式化),梦核判断完全交给 Claude,不写打分公式
|
||||
- usage.json 存各项目本地,不上传服务器,符合炼境 document-driven 设计哲学
|
||||
- 节流策略:同一天一条,避免频繁打开蓝图导致数据膨胀
|
||||
- conventions_version 打标是版本对比的关键,是外环版本评估的基础
|
||||
- 4项指标中「可派发率」衡量任务卡规划质量,「飞轮持续率」衡量外环是否在转,两者是最先导指标;任务完成率衡量整体执行进度,阻塞密度是系统性风险信号
|
||||
- iteration 字段用于区分历史 done 和当前迭代 done,避免多次重构后完成率虚高;迭代跳变是正常信号(主动重构),梦核据此判断而非告警
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
id: blueprint-governance
|
||||
name: 蓝图治理
|
||||
status: planned
|
||||
progress: 0
|
||||
status: done
|
||||
progress: 100
|
||||
---
|
||||
|
||||
## 概述
|
||||
@ -13,36 +13,37 @@ progress: 0
|
||||
- CONVENTIONS.md 加 YAML frontmatter 版本号,应用以此判断是否需要同步
|
||||
- CLAUDE.md 用 `<!-- BLUEPRINT_MANAGED_START -->` / `<!-- BLUEPRINT_MANAGED_END -->` 标记管理区,应用只替换标记内内容,不动项目自有内容
|
||||
- 无 CLAUDE.md 时生成初始文件;有 CLAUDE.md 但无 MANAGED 区时追加到末尾
|
||||
- MASTER_CONVENTIONS 通过 `include_str!()` 在编译时嵌入,保证 binary 自包含
|
||||
- BlueprintStatus 四状态:none / rules_outdated / content_stale / synced
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 CONVENTIONS.md 版本化
|
||||
- status: todo
|
||||
### ✅ CONVENTIONS.md 版本化
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: .blueprint/CONVENTIONS.md
|
||||
- acceptance: frontmatter 加 version 和 updated 字段,格式为 semver(如 1.0.0)
|
||||
|
||||
### 📋 get_blueprint_status command
|
||||
- status: todo
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
- acceptance: 接收项目路径,返回四种状态之一:none / rules_outdated / content_stale / synced;判断依据:.blueprint/ 是否存在、CONVENTIONS.md 版本是否匹配 master、CLAUDE.md 是否有 MANAGED 区且版本匹配
|
||||
|
||||
### 📋 sync_blueprint_rules command
|
||||
- status: todo
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
- acceptance: 接收项目路径,执行三步:(1) 覆盖写入最新 CONVENTIONS.md;(2) 检测 CLAUDE.md 是否存在及 MANAGED 区状态,按三种情况(无文件/无区块/版本旧)分别处理;(3) 返回操作摘要
|
||||
|
||||
### 📋 CLAUDE.md 模板定义
|
||||
- status: todo
|
||||
### ✅ CLAUDE.md 模板定义
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
- acceptance: 在 Rust 中定义 CLAUDE.md MANAGED 区的标准内容模板,内容涵盖:蓝图飞轮流程、读取 manifest 指令、任务卡派发规则;模板版本与 CONVENTIONS.md 同步
|
||||
|
||||
### 📋 批量同步所有项目规则
|
||||
- status: todo
|
||||
### ✅ get_blueprint_status command
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- acceptance: 接收项目路径,返回四种状态之一:none / rules_outdated / content_stale / synced;判断依据:.blueprint/ 是否存在、CONVENTIONS.md 版本是否匹配 master、CLAUDE.md 是否有 MANAGED 区且版本匹配
|
||||
|
||||
### ✅ sync_blueprint_rules command
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- acceptance: 接收项目路径,执行三步:(1) 覆盖写入最新 CONVENTIONS.md;(2) 检测 CLAUDE.md 是否存在及 MANAGED 区状态,按三种情况(无文件/无区块/版本旧)分别处理;(3) 返回操作摘要
|
||||
|
||||
### ✅ 批量同步所有项目规则
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/commands/blueprint.rs, src/components/settings/SettingsPage.tsx
|
||||
- depends: sync_blueprint_rules command
|
||||
- files: src/components/settings/SettingsPage.tsx
|
||||
- acceptance: 设置页提供「同步所有项目蓝图规则」按钮,调用 sync_blueprint_rules 遍历所有已注册项目,返回每个项目的同步结果摘要
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
id: blueprint-onboarding
|
||||
name: 蓝图接入引导
|
||||
status: planned
|
||||
progress: 0
|
||||
status: done
|
||||
progress: 100
|
||||
---
|
||||
|
||||
## 概述
|
||||
@ -13,26 +13,26 @@ progress: 0
|
||||
- 提示词生成由 Rust 完成(扫描目录结构、读取 git log、拼接模板),前端只做展示
|
||||
- 提示词包含完整上下文(项目信息 + 目录结构 + 现有蓝图 + CONVENTIONS 摘要),确保 Claude 无需额外询问
|
||||
- 目录扫描限深度 3 层,避免输出过大
|
||||
- 配置文件扫描采用「存在则读」策略:AI 文档(ARCHITECTURE.md/CLAUDE.md/AGENTS.md)自动包含,对无此类文件的简单项目零影响
|
||||
- monorepo 自动感知:检测到 pnpm-workspace.yaml 等标志文件后,自动读子包配置并调整模块数建议,用户无需手动区分项目复杂度
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 generate_blueprint_prompt command
|
||||
- status: todo
|
||||
### ✅ generate_blueprint_prompt command
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
- depends: blueprint-governance
|
||||
- acceptance: 接收项目路径和提示词类型(init / sync),返回对应提示词字符串;init 类型扫描目录结构 + 读取 README/package.json/Cargo.toml;sync 类型额外读取现有蓝图文件 + 近 20 条 git log
|
||||
- acceptance: 接收项目路径和提示词类型(init / sync),返回对应提示词字符串;init 类型扫描目录结构 + 读取 README/package.json/Cargo.toml/ARCHITECTURE.md/CLAUDE.md/AGENTS.md/DEVELOPMENT.md(存在才读,不存在跳过);monorepo 自动检测(pnpm-workspace.yaml/lerna.json/turbo.json),检测到则额外读 apps/ 前4个子包 + packages/ 前2个子包的 package.json,并在提示词中注入 monorepo 提示(模块数建议 10-25、以业务能力为单位划分);sync 类型额外读取现有蓝图文件 + 近 20 条 git log
|
||||
|
||||
### 📋 项目列表蓝图状态徽章
|
||||
- status: todo
|
||||
### ✅ 项目列表蓝图状态徽章
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/ProjectCard.tsx
|
||||
- depends: get_blueprint_status command
|
||||
- acceptance: 项目卡片显示蓝图状态徽章(🔘无蓝图 / 🟡规则待更新 / 🟠内容待同步 / 🟢已同步);🟡状态点击直接执行同步(无需 Claude);其余状态点击打开操作弹窗
|
||||
|
||||
### 📋 蓝图操作弹窗
|
||||
- status: todo
|
||||
### ✅ 蓝图操作弹窗
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/BlueprintPromptModal.tsx
|
||||
- depends: generate_blueprint_prompt command
|
||||
- acceptance: 弹窗展示生成的提示词(monospace、可选中)、一键复制按钮、说明文案("复制后粘贴给 Claude Opus 执行");🔘状态显示「初始化蓝图」提示词,🟠状态显示「同步描绘」提示词
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
id: blueprint-view
|
||||
name: 蓝图可视化
|
||||
status: done
|
||||
progress: 100
|
||||
status: in_progress
|
||||
progress: 86
|
||||
---
|
||||
|
||||
## 概述
|
||||
@ -78,6 +78,30 @@ progress: 100
|
||||
- files: src/components/dashboard/BlueprintModal.tsx
|
||||
- acceptance: 根据模块相对位置自动选择最短路径 handle,避免连线绕远
|
||||
|
||||
### ✅ 蓝图治理面板
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
|
||||
- acceptance: 侧边新增「治理」面板,展示 CONVENTIONS.md 当前版本与 master 版本对比;提供「同步规则」按钮(替代设置页的同步入口);提供「初始化/同步蓝图」提示词入口(替代卡片徽章弹窗,统一收口)
|
||||
|
||||
### ✅ 顶部状态行扩展
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/components/dashboard/BlueprintModal.tsx
|
||||
- depends: blueprint-governance
|
||||
- acceptance: 统计条新增「规则」状态指示(🟢已同步/🟡待更新),点击跳转到治理面板;「AI文档」指示器预留位置但灰显(depends: blueprint-feedback 未完成时不可用,避免跳转到不存在的面板)
|
||||
|
||||
### ✅ 批量执行提示词入口
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/components/dashboard/BlueprintModal.tsx
|
||||
- depends: blueprint-view
|
||||
- acceptance: 下一步汇总面板新增「复制批量执行提示词」按钮;点击后将当前项目的批量执行提示词复制到剪贴板;提示词包含项目名称,指示 Claude 自主遍历所有 📋 任务卡依次执行(标记 🔵 加锁 → 实现 → 标记 ✅ 解锁,失败标 🔴 blocked),中途不等用户确认,全部完成后输出执行报告;提示词纯前端生成,无需新增 Tauri command
|
||||
|
||||
### 💭 AI 文档健康面板
|
||||
- status: concept
|
||||
- notes: 侧边新增「AI文档」面板,列出项目内所有 AI 相关文档(CLAUDE.md/AGENTS.md/.cursorrules 等)及其使用频率评分;标出休眠文档;提供「归档」操作。依赖 ai-doc-analytics 模块的埋点数据。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 选择 React Flow (@xyflow/react) 而非 D3 或自绘 canvas,因为 React Flow 提供开箱即用的节点拖拽、缩放、连线路由
|
||||
@ -85,3 +109,4 @@ progress: 100
|
||||
- 区域分组用 React Flow 父子节点实现,模块作为子节点自动跟随分组
|
||||
- hover 只做边高亮,点击(pin)才做节点淡化,避免 React Flow 节点重建导致的闪烁
|
||||
- 飞轮模型分配:Opus 做架构需求和蓝图维护,Sonnet 执行任务卡,.blueprint/ 文件作为模型间共享状态
|
||||
- 蓝图弹窗定位为项目蓝图驾驶舱:所有蓝图相关操作(治理、AI文档健康、提示词生成)统一收口到此弹窗,不再分散到设置页和卡片徽章
|
||||
|
||||
40
.blueprint/modules/mcp-config-inject.md
Normal file
40
.blueprint/modules/mcp-config-inject.md
Normal file
@ -0,0 +1,40 @@
|
||||
# MCP 配置注入
|
||||
|
||||
产品组成员变更时,炼境自动向对应项目的 `.claude/settings.json` 注入或移除 MCP 服务器配置,实现热插拔。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key,与用户自有配置严格隔离
|
||||
- 炼境只操作自己命名空间下的 key,其余配置永远不动
|
||||
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
|
||||
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ 加入产品组时注入配置
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/groups.rs
|
||||
- depends: mcp-server
|
||||
- acceptance:
|
||||
- 项目加入产品组后,其 win_path 或 wsl_path 对应的 `.claude/settings.json` 被写入以下条目
|
||||
- key 格式:`lian-jing:group-{group_id}`
|
||||
- value:`{ "type": "http", "url": "http://localhost:{port}/mcp/group/{group_id}" }`
|
||||
- 文件不存在时自动创建;文件已存在时合并写入,不影响其他 key
|
||||
- 文件 JSON 损坏时 eprintln 记录,不影响成员关系 DB 写入
|
||||
|
||||
### ✅ 退出产品组时清除配置
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/groups.rs
|
||||
- depends: mcp-config-inject(注入)
|
||||
- acceptance:
|
||||
- 项目退出产品组后,`lian-jing:group-{group_id}` 这一 key 被精确删除
|
||||
- 其他 key(包括其他产品组的 key、用户自有配置)完全不受影响
|
||||
- 在 DB 删除前先执行清除(remove_project_mcp 需要 project_workspaces 查路径)
|
||||
|
||||
### ✅ 端口变更时重新注入所有配置
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/settings.rs
|
||||
- acceptance: update_settings 检测到 mcp_port key 变更后自动调用 reinject_all_mcp,遍历所有 project_group_map 记录重新写入 settings.json
|
||||
41
.blueprint/modules/mcp-server.md
Normal file
41
.blueprint/modules/mcp-server.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 内嵌 MCP Server
|
||||
|
||||
炼境进程内置一个 axum HTTP 服务器,实现 MCP 协议,按产品组聚合上下文数据,供组内所有项目的 Claude 实例查询。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 内嵌在 Tauri 进程而非独立进程,因为炼境托盘常驻时天然保证服务存活,无需额外进程管理
|
||||
- 使用 HTTP/SSE 传输而非 stdio,因为 HTTP 可被 Windows 和 WSL 两侧同时访问(WSL localhost 穿透)
|
||||
- 默认端口 27190(冷门端口,冲突概率极低),可在设置页修改
|
||||
- MCP 工具按 group_id 路由,不同产品组的数据完全隔离
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ 启动时绑定 HTTP 端口
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs, src-tauri/src/lib.rs, src-tauri/Cargo.toml
|
||||
- depends: tray-service
|
||||
- acceptance: 炼境启动后 `http://localhost:27190/health` 返回 200,进程退出后端口释放
|
||||
|
||||
### ✅ 实现 MCP JSON-RPC 基础协议
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- acceptance: 支持 `initialize`、`tools/list`、`tools/call` 三个 JSON-RPC 方法,通过 MCP Inspector 验证
|
||||
|
||||
### ✅ 实现产品组上下文工具
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- depends: mcp-server(基础协议)
|
||||
- acceptance: 以下三个工具可用且返回正确数据
|
||||
- `list_group_projects(group_id)` → 组内项目列表(名称、路径、平台、tech_stack)
|
||||
- `get_project_blueprint(group_id, project_id)` → 对应项目的 manifest.yaml 内容
|
||||
- `get_project_file(group_id, project_id, relative_path)` → 对应项目的任意文件内容
|
||||
|
||||
### ✅ MCP 端口可配置(Rust 侧)
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/db.rs, src-tauri/src/mcp_server.rs
|
||||
- acceptance: settings 表默认写入 mcp_port=27190,启动时读取该值绑定端口(SettingsPage UI 见 product-group-mcp 模块)
|
||||
25
.blueprint/modules/product-group-mcp.md
Normal file
25
.blueprint/modules/product-group-mcp.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 产品组 MCP 设置
|
||||
|
||||
产品组 UI 中展示 MCP 激活状态,让用户可感知哪些项目已接入跨项目上下文能力。
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ 产品组详情显示 MCP 注入状态
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/components/manage/ManagePage.tsx
|
||||
- depends: mcp-config-inject
|
||||
- acceptance:
|
||||
- 产品组成员卡片显示平台标识(Win / WSL)
|
||||
- 已配置路径的项目显示绿点 "MCP" 徽标,hover 显示路径
|
||||
- 未配置路径的项目显示「无路径」灰色徽标,说明注入已跳过
|
||||
|
||||
### ✅ 设置页添加 MCP 端口配置
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/components/settings/SettingsPage.tsx, src-tauri/src/commands/settings.rs
|
||||
- depends: mcp-server(端口可配置)
|
||||
- acceptance:
|
||||
- 设置页新增「MCP 服务」分区,含端口输入框(默认 27190)和绿色「运行中」状态
|
||||
- 保存时校验端口范围(1024~65535),调用 updateSettings({mcp_port})
|
||||
- Rust 侧自动触发 reinject_all_mcp,所有成员配置即时更新
|
||||
28
.blueprint/modules/tray-service.md
Normal file
28
.blueprint/modules/tray-service.md
Normal file
@ -0,0 +1,28 @@
|
||||
# 系统托盘常驻
|
||||
|
||||
炼境作为后台服务常驻,关闭窗口后进程继续运行,保持 MCP Server 持续提供服务。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 选择托盘常驻而非动态端口,因为 MCP 配置注入写静态 URL,进程不常驻则 Claude 连不上
|
||||
- 使用 tauri-plugin-single-instance 保证进程唯一,从根本上消除端口冲突风险
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ 集成 tauri-plugin-single-instance
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/Cargo.toml, src-tauri/src/lib.rs, src-tauri/tauri.conf.json
|
||||
- acceptance: 双击启动炼境时不新建进程,自动激活已有窗口并置顶
|
||||
|
||||
### ✅ 系统托盘图标与菜单
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/lib.rs, src-tauri/tauri.conf.json
|
||||
- acceptance: 任务栏托盘显示炼境图标,右键菜单有「显示窗口」「退出炼境」两项
|
||||
|
||||
### ✅ 关闭窗口最小化到托盘
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/lib.rs
|
||||
- acceptance: 点击窗口关闭按钮时,窗口隐藏但进程不退出;点「退出炼境」才真正结束进程
|
||||
26
.blueprint/usage.json
Normal file
26
.blueprint/usage.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"project_name": "dev-manager-tauri",
|
||||
"snapshots": [
|
||||
{
|
||||
"at": "2026-04-03T04:52:50Z",
|
||||
"blocked_reasons": [],
|
||||
"conventions_version": "1.1.0",
|
||||
"modules": {
|
||||
"blocked": 0,
|
||||
"concept": 0,
|
||||
"done": 22,
|
||||
"in_progress": 1,
|
||||
"planned": 2,
|
||||
"total": 25
|
||||
},
|
||||
"tasks": {
|
||||
"blocked": 0,
|
||||
"dispatched": 4,
|
||||
"done": 84,
|
||||
"in_progress": 0,
|
||||
"todo": 7,
|
||||
"total": 95
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Dev Manager ${{ github.ref_name }}
|
||||
name: 炼境 ${{ github.ref_name }}
|
||||
body: |
|
||||
## 更新内容
|
||||
请查看提交记录了解详情。
|
||||
@ -69,7 +69,7 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: Dev Manager ${{ github.ref_name }}
|
||||
releaseName: 炼境 ${{ github.ref_name }}
|
||||
releaseBody: |
|
||||
## 更新内容
|
||||
请查看提交记录了解详情。
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@ -1,4 +1,4 @@
|
||||
# Dev Manager Tauri
|
||||
# 炼境 (Dev Manager Tauri)
|
||||
|
||||
Git 多空间开发管理器,Tauri v2 + React + TypeScript + Rust。
|
||||
|
||||
@ -42,3 +42,28 @@ src-tauri/src/commands/ Tauri command 实现
|
||||
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
||||
docs/ 部署和更新指南
|
||||
```
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.3.0" -->
|
||||
## 项目蓝图
|
||||
|
||||
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
||||
|
||||
**每次对话都应遵循以下流程:**
|
||||
|
||||
### 1. 了解当前状态
|
||||
对话开始时,先读取 `.blueprint/manifest.yaml` 了解项目模块和状态。
|
||||
|
||||
### 2. 讨论需求时更新蓝图
|
||||
- 确认了新需求 → 在 manifest.yaml 新增模块,创建 `.blueprint/modules/<id>.md`
|
||||
- 拆分了大需求 → 在模块文件中添加任务卡
|
||||
- 调整了方向 → 更新模块关系(edges)
|
||||
|
||||
### 3. 完成实现后标记蓝图
|
||||
- 完成了功能 → 更新对应任务卡状态为 `done`(前缀改为 ✅)
|
||||
- 更新 manifest.yaml 顶部的 `updated` 日期
|
||||
|
||||
### 4. 任务卡规范
|
||||
详见 `.blueprint/CONVENTIONS.md`,核心规则:
|
||||
- 任务卡同时具备 `files` + `acceptance` + complexity `S/M` → 标记为 📋 可派发
|
||||
- 可派发的任务卡可以直接交给 Claude Agent 独立完成
|
||||
<!-- BLUEPRINT_MANAGED_END -->
|
||||
|
||||
99
src-tauri/Cargo.lock
generated
99
src-tauri/Cargo.lock
generated
@ -234,6 +234,61 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@ -827,6 +882,7 @@ dependencies = [
|
||||
name = "dev-manager-tauri"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"git2",
|
||||
@ -845,7 +901,9 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"ureq",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@ -1780,6 +1838,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@ -1794,6 +1858,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@ -2406,6 +2471,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@ -4050,6 +4121,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@ -4792,6 +4874,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
@ -5222,6 +5319,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5260,6 +5358,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
||||
@ -34,6 +34,9 @@ git2 = { version = "0.19", features = ["vendored-openssl"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["net"] }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
open = "5"
|
||||
base64 = "0.22"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
||||
use crate::db::pool;
|
||||
use crate::mcp_inject;
|
||||
use crate::models::{ProductGroup, ProjectGroupMap};
|
||||
use rusqlite::params;
|
||||
use serde::Deserialize;
|
||||
@ -64,6 +65,19 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
pub fn delete_group(id: String) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
// 先清除所有成员的 MCP 配置(DB cascade 删除之前)
|
||||
let member_ids: Vec<String> = conn
|
||||
.prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map(params![id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for project_id in &member_ids {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(project_id, &id) {
|
||||
eprintln!("[MCP remove] 删除组时清除失败 project={} group={}: {}", project_id, id, e);
|
||||
}
|
||||
}
|
||||
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
@ -102,12 +116,20 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
|
||||
params![project_id, group_id, role],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(&project_id, &group_id) {
|
||||
eprintln!("[MCP inject] 注入失败 project={} group={}: {}", project_id, group_id, e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
// 先清除 MCP 配置(需要在 DB 记录删除前查询项目路径)
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id, &group_id) {
|
||||
eprintln!("[MCP remove] 清除失败 project={} group={}: {}", project_id, group_id, e);
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
||||
params![project_id, group_id],
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use crate::db::pool;
|
||||
use crate::mcp_inject;
|
||||
use crate::models::{ActivityLog, Project};
|
||||
use rusqlite::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -276,6 +277,20 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
|
||||
)
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
// 清理产品组 MCP 配置(在删除 project_group_map 记录之前)
|
||||
let group_ids: Vec<String> = conn
|
||||
.prepare("SELECT group_id FROM project_group_map WHERE project_id = ?1")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map(params![id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for group_id in &group_ids {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&id, group_id) {
|
||||
eprintln!("[MCP remove] 删除项目时清除失败 project={} group={}: {}", id, group_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
|
||||
for table in &["activity_log", "project_tools", "project_group_map",
|
||||
"project_env_tools", "deploy_commands"] {
|
||||
|
||||
@ -12,6 +12,7 @@ const GLOBAL_KEYS: &[&str] = &[
|
||||
"v2rayn_db_path", // v2rayN 数据库路径
|
||||
"v2raya_username", // v2rayA 登录用户名
|
||||
"v2raya_password", // v2rayA 登录密码
|
||||
"mcp_port", // MCP Server 监听端口(全局,与空间无关)
|
||||
];
|
||||
|
||||
/// 获取 settings:
|
||||
@ -60,7 +61,11 @@ pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>,
|
||||
#[tauri::command]
|
||||
pub fn update_settings(settings: HashMap<String, String>, space_id: Option<String>) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
for (k, v) in settings {
|
||||
let mut mcp_port_changed = false;
|
||||
for (k, v) in &settings {
|
||||
if k == "mcp_port" {
|
||||
mcp_port_changed = true;
|
||||
}
|
||||
if GLOBAL_KEYS.contains(&k.as_str()) || space_id.is_none() {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
@ -75,6 +80,13 @@ pub fn update_settings(settings: HashMap<String, String>, space_id: Option<Strin
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
// 端口变更后重新注入所有产品组成员的 MCP 配置
|
||||
if mcp_port_changed {
|
||||
let errors = crate::mcp_inject::reinject_all_mcp();
|
||||
if !errors.is_empty() {
|
||||
eprintln!("[MCP reinject] 部分项目重注入失败: {:?}", errors);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -128,6 +128,7 @@ fn migrate(conn: &rusqlite::Connection) {
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('dev_mode', '0');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_id', '');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_secret', '');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190');
|
||||
",
|
||||
)
|
||||
.expect("migrate");
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
mod commands;
|
||||
mod db;
|
||||
mod mcp_inject;
|
||||
mod mcp_server;
|
||||
mod models;
|
||||
use git2;
|
||||
|
||||
@ -8,6 +10,15 @@ use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_o
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
// 第二个实例启动时,激活已有窗口
|
||||
use tauri::Manager;
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
@ -15,7 +26,12 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
use tauri::Manager;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager,
|
||||
};
|
||||
|
||||
let data_dir = app.path().app_data_dir().expect("no app data dir");
|
||||
db::init(data_dir);
|
||||
// 禁用 libgit2 目录所有者验证,允许访问 WSL UNC 路径(\\wsl.localhost\...)
|
||||
@ -24,6 +40,59 @@ pub fn run() {
|
||||
}
|
||||
// 种植内置模板(首次启动时执行)
|
||||
commands::templates::seed_builtin_templates();
|
||||
|
||||
// ── 启动 MCP Server ───────────────────────────────────────
|
||||
let mcp_port = mcp_server::read_port();
|
||||
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
|
||||
|
||||
// ── 系统托盘 ──────────────────────────────────────────────
|
||||
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("炼境")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"quit" => app.exit(0),
|
||||
"show" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// 左键单击托盘图标 → 显示/聚焦窗口
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// ── 关闭窗口 → 最小化到托盘 ──────────────────────────────
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let win = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = win.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@ -150,6 +219,12 @@ pub fn run() {
|
||||
generate_workflow,
|
||||
// blueprint
|
||||
get_blueprint,
|
||||
generate_blueprint_prompt,
|
||||
get_blueprint_status,
|
||||
sync_blueprint_rules,
|
||||
get_flywheel_stats,
|
||||
generate_muhe_prompt,
|
||||
archive_project_docs,
|
||||
// canvas state
|
||||
get_canvas_state,
|
||||
save_canvas_state,
|
||||
|
||||
142
src-tauri/src/mcp_inject.rs
Normal file
142
src-tauri/src/mcp_inject.rs
Normal file
@ -0,0 +1,142 @@
|
||||
use crate::db;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
|
||||
|
||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径)
|
||||
fn project_root(project_id: &str) -> Result<PathBuf, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
|
||||
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
|
||||
[project_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
);
|
||||
|
||||
let (win_path, wsl_path, platform) =
|
||||
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||||
|
||||
if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||||
let distro: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||||
let inner = wsl.trim_start_matches('/').replace('/', "\\");
|
||||
Ok(PathBuf::from(format!(
|
||||
"\\\\wsl.localhost\\{}\\{}",
|
||||
distro, inner
|
||||
)))
|
||||
} else {
|
||||
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
|
||||
Ok(PathBuf::from(win))
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_root(project_id)?.join(".claude").join("settings.json"))
|
||||
}
|
||||
|
||||
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn read_json(path: &PathBuf) -> Result<Value, String> {
|
||||
if !path.exists() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("读取 settings.json 失败: {}", e))?;
|
||||
if content.trim().is_empty() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
serde_json::from_str(&content).map_err(|e| {
|
||||
format!(
|
||||
"settings.json 格式错误,请手动修复后重试({}): {}",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
|
||||
}
|
||||
|
||||
// ── 公开接口 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
|
||||
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||||
let port = crate::mcp_server::read_port();
|
||||
let path = settings_path(project_id)?;
|
||||
let mut settings = read_json(&path)?;
|
||||
|
||||
let servers = settings
|
||||
.as_object_mut()
|
||||
.ok_or("settings.json 顶层必须是 JSON 对象")?
|
||||
.entry("mcpServers")
|
||||
.or_insert(json!({}));
|
||||
|
||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||||
servers[&key] = json!({
|
||||
"type": "http",
|
||||
"url": format!("http://localhost:{}/mcp/group/{}", port, group_id)
|
||||
});
|
||||
|
||||
write_json(&path, &settings)
|
||||
}
|
||||
|
||||
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
|
||||
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||||
let path = settings_path(project_id)?;
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut settings = read_json(&path)?;
|
||||
|
||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||||
if let Some(servers) = settings
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
servers.remove(&key);
|
||||
}
|
||||
|
||||
write_json(&path, &settings)
|
||||
}
|
||||
|
||||
/// 端口变更后重新注入所有产品组成员的 MCP 配置
|
||||
/// 返回注入失败的错误列表(部分失败不影响其他项目)
|
||||
pub fn reinject_all_mcp() -> Vec<String> {
|
||||
let conn = match db::pool().get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return vec![e.to_string()],
|
||||
};
|
||||
|
||||
let mappings: Vec<(String, String)> = {
|
||||
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
|
||||
Ok(s) => s,
|
||||
Err(e) => return vec![e.to_string()],
|
||||
};
|
||||
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for (project_id, group_id) in mappings {
|
||||
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
|
||||
errors.push(format!("项目 {}: {}", project_id, e));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
289
src-tauri/src/mcp_server.rs
Normal file
289
src-tauri/src/mcp_server.rs
Normal file
@ -0,0 +1,289 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::db;
|
||||
|
||||
/// 从 settings 表读取 MCP 端口,默认 27190
|
||||
pub fn read_port() -> u16 {
|
||||
db::pool()
|
||||
.get()
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
c.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'mcp_port'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(27190)
|
||||
}
|
||||
|
||||
/// 启动 MCP HTTP 服务(Streamable HTTP transport)
|
||||
pub async fn start(port: u16) {
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/mcp/group/:group_id", post(handle_mcp));
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(listener) => {
|
||||
println!("[MCP] 炼境 MCP Server 已启动,端口 {port}");
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
eprintln!("[MCP] Server error: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[MCP] 绑定端口 {port} 失败: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"炼境 MCP Server running"
|
||||
}
|
||||
|
||||
// ── JSON-RPC 类型 ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RpcRequest {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
id: Option<Value>,
|
||||
#[serde(default)]
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
|
||||
// MCP 通知(无 id)→ 202 Accepted,无需响应体
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
|
||||
let id = req.id.clone();
|
||||
|
||||
let result = match req.method.as_str() {
|
||||
"initialize" => json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": {
|
||||
"name": "炼境",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}),
|
||||
"tools/list" => tools_list(),
|
||||
"tools/call" => tools_call(&group_id, req.params.as_ref()).await,
|
||||
_ => {
|
||||
return Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": { "code": -32601, "message": "Method not found" }
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response()
|
||||
}
|
||||
|
||||
// ── 工具定义 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn tools_list() -> Value {
|
||||
json!({
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_group_projects",
|
||||
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_project_blueprint",
|
||||
"description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml),了解该项目的模块与任务状态",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "项目 workspace ID(来自 list_group_projects 的返回结果)"
|
||||
}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_project_file",
|
||||
"description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "项目 workspace ID"
|
||||
},
|
||||
"relative_path": {
|
||||
"type": "string",
|
||||
"description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts"
|
||||
}
|
||||
},
|
||||
"required": ["project_id", "relative_path"]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// ── 工具调用 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
let tool_name = params
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let args = params
|
||||
.and_then(|p| p.get("arguments"))
|
||||
.cloned()
|
||||
.unwrap_or(json!({}));
|
||||
let gid = group_id.to_string();
|
||||
|
||||
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
||||
"list_group_projects" => list_group_projects(&gid),
|
||||
"get_project_blueprint" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
get_project_blueprint(&gid, pid)
|
||||
}
|
||||
"get_project_file" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let rel = args
|
||||
.get("relative_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
get_project_file(&gid, pid, rel)
|
||||
}
|
||||
_ => Err(format!("未知工具: {}", tool_name)),
|
||||
})
|
||||
.await;
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }),
|
||||
Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
|
||||
Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具实现(同步,由 spawn_blocking 包裹)──────────────────────────────────
|
||||
|
||||
fn list_group_projects(group_id: &str) -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path,
|
||||
pp.tech_stack, pgm.role
|
||||
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",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows: Vec<String> = stmt
|
||||
.query_map([group_id], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let platform: Option<String> = row.get(2)?;
|
||||
let win_path: Option<String> = row.get(3)?;
|
||||
let wsl_path: Option<String> = row.get(4)?;
|
||||
let tech_stack: Option<String> = row.get(5)?;
|
||||
let role: Option<String> = row.get(6)?;
|
||||
Ok(format!(
|
||||
"- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}",
|
||||
name,
|
||||
id,
|
||||
platform.as_deref().unwrap_or("unknown"),
|
||||
tech_stack.as_deref().unwrap_or("未配置"),
|
||||
role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(),
|
||||
win_path.as_deref().unwrap_or("未配置"),
|
||||
wsl_path.as_deref().unwrap_or("未配置"),
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok("该产品组暂无成员项目。".to_string());
|
||||
}
|
||||
Ok(format!(
|
||||
"产品组成员(共 {} 个项目):\n\n{}",
|
||||
rows.len(),
|
||||
rows.join("\n\n")
|
||||
))
|
||||
}
|
||||
|
||||
fn get_project_blueprint(group_id: &str, project_id: &str) -> Result<String, String> {
|
||||
let root = resolve_project_root(group_id, project_id)?;
|
||||
let path = root.join(".blueprint").join("manifest.yaml");
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e))
|
||||
}
|
||||
|
||||
fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result<String, String> {
|
||||
if relative_path.contains("..") {
|
||||
return Err("路径不允许包含 \"..\"".to_string());
|
||||
}
|
||||
let root = resolve_project_root(group_id, project_id)?;
|
||||
let path = root.join(relative_path);
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取文件失败({}): {}", path.display(), e))
|
||||
}
|
||||
|
||||
/// 根据 group_id + project_id 解析项目根目录路径
|
||||
/// WSL 项目自动转换为 Windows UNC 路径(\\wsl.localhost\<distro>\...)
|
||||
fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
|
||||
"SELECT pw.win_path, pw.wsl_path, pw.platform
|
||||
FROM project_group_map pgm
|
||||
JOIN project_workspaces pw ON pw.id = pgm.project_id
|
||||
WHERE pgm.group_id = ?1 AND pgm.project_id = ?2",
|
||||
[group_id, project_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
);
|
||||
|
||||
let (win_path, wsl_path, platform) = row.map_err(|_| {
|
||||
format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)
|
||||
})?;
|
||||
|
||||
if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||||
let distro: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||||
// /home/user/project → \\wsl.localhost\Ubuntu\home\user\project
|
||||
let inner = wsl.trim_start_matches('/').replace('/', "\\");
|
||||
Ok(std::path::PathBuf::from(format!(
|
||||
"\\\\wsl.localhost\\{}\\{}",
|
||||
distro, inner
|
||||
)))
|
||||
} else {
|
||||
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
|
||||
Ok(std::path::PathBuf::from(win))
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Dev Manager",
|
||||
"title": "炼境",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@ -11,16 +11,30 @@ import { Toast } from "./components/ui/Toast";
|
||||
import LoginPage from "./components/auth/LoginPage";
|
||||
import { githubGetAccount, githubLogout, type GitAccount } from "./lib/commands";
|
||||
import { useSpacesSync } from "./hooks/useSpacesSync";
|
||||
import { useUIStore } from "./store/ui";
|
||||
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>("overview");
|
||||
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
|
||||
const qc = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
|
||||
useEffect(() => {
|
||||
githubGetAccount().then(setAccount).catch(() => setAccount(null));
|
||||
}, []);
|
||||
|
||||
// 启动时静默检查更新
|
||||
useEffect(() => {
|
||||
checkUpdate()
|
||||
.then((update) => {
|
||||
if (update?.available) {
|
||||
showToast(`🆕 发现新版本 v${update.version},前往「设置」页更新`);
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // 静默失败,不打扰用户
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await githubLogout();
|
||||
qc.clear();
|
||||
|
||||
@ -14,10 +14,22 @@ import {
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
getBlueprint,
|
||||
getBlueprintStatus,
|
||||
syncBlueprintRules,
|
||||
generateBlueprintPrompt,
|
||||
getProjects,
|
||||
getFlywheelStats,
|
||||
generateMuhePrompt,
|
||||
archiveProjectDocs,
|
||||
type BlueprintData,
|
||||
type BlueprintModule,
|
||||
type BlueprintTask,
|
||||
type BlueprintArea,
|
||||
type BlueprintStatus,
|
||||
type SyncResult,
|
||||
type Project,
|
||||
type FlywheelStats,
|
||||
type StalledProject,
|
||||
} from "../../lib/commands";
|
||||
|
||||
interface Props {
|
||||
@ -222,7 +234,7 @@ function pickHandles(
|
||||
|
||||
// ── 主组件 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | null;
|
||||
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | { type: "governance" } | { type: "flywheel" } | null;
|
||||
|
||||
export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
|
||||
const [sidePanel, setSidePanel] = useState<SidePanel>(null);
|
||||
@ -237,6 +249,12 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
|
||||
queryFn: () => getBlueprint(projectPath),
|
||||
});
|
||||
|
||||
const { data: bpStatus } = useQuery<BlueprintStatus>({
|
||||
queryKey: ["bp-status", projectPath],
|
||||
queryFn: () => getBlueprintStatus(projectPath),
|
||||
enabled: !!projectPath,
|
||||
});
|
||||
|
||||
const handleNodeClick = useCallback((mod: BlueprintModule) => {
|
||||
setSidePanel({ type: "module", module: mod });
|
||||
setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id));
|
||||
@ -363,14 +381,44 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">
|
||||
×
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSidePanel((p) => p?.type === "flywheel" ? null : { type: "flywheel" })}
|
||||
className={`text-xs px-2.5 py-1 rounded-lg transition-colors ${
|
||||
sidePanel?.type === "flywheel"
|
||||
? "text-white bg-emerald-600"
|
||||
: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
|
||||
}`}
|
||||
>
|
||||
♻️ 飞轮
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSidePanel((p) => p?.type === "governance" ? null : { type: "governance" })}
|
||||
className={`text-xs px-2.5 py-1 rounded-lg transition-colors ${
|
||||
sidePanel?.type === "governance"
|
||||
? "text-white bg-violet-600"
|
||||
: "text-violet-600 bg-violet-50 hover:bg-violet-100"
|
||||
}`}
|
||||
>
|
||||
⚙️ 治理
|
||||
</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计条 */}
|
||||
{stats && (
|
||||
<div className="px-5 py-2.5 border-b border-gray-100 flex items-center gap-4 shrink-0 flex-wrap">
|
||||
{blueprint?.manifest.iteration != null && blueprint.manifest.iteration > 1 && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-600 shrink-0 font-medium"
|
||||
title="当前迭代号(模块重构时递增)"
|
||||
>
|
||||
迭代 {blueprint.manifest.iteration}
|
||||
</span>
|
||||
)}
|
||||
<StatBadge color="#22C55E" label="已完成" count={stats.done} />
|
||||
<StatBadge color="#3B82F6" label="进行中" count={stats.in_progress} />
|
||||
<StatBadge color="#EAB308" label="已规划" count={stats.planned} />
|
||||
@ -406,6 +454,30 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
|
||||
📋 {stats.dispatchable} 可派发
|
||||
</button>
|
||||
)}
|
||||
{/* 规则 & AI文档 状态指示器 */}
|
||||
{bpStatus && (
|
||||
<button
|
||||
onClick={() => setSidePanel((p) => p?.type === "governance" ? null : { type: "governance" })}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 ${
|
||||
bpStatus.status === "synced"
|
||||
? "text-green-600 bg-green-50 hover:bg-green-100"
|
||||
: "text-yellow-600 bg-yellow-50 hover:bg-yellow-100"
|
||||
}`}
|
||||
>
|
||||
{bpStatus.status === "synced" ? "🟢 规则" : "🟡 规则"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidePanel((p) => p?.type === "flywheel" ? null : { type: "flywheel" })}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 ${
|
||||
sidePanel?.type === "flywheel"
|
||||
? "text-white bg-emerald-600"
|
||||
: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
|
||||
}`}
|
||||
title="梦核·记忆巩固 / AI文档归档"
|
||||
>
|
||||
♻️ AI文档
|
||||
</button>
|
||||
{/* 总进度条(固定在右侧) */}
|
||||
<div className="flex-1" />
|
||||
{pinnedModuleId && (
|
||||
@ -514,9 +586,19 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
|
||||
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
|
||||
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
|
||||
/>
|
||||
) : sidePanel.type === "governance" ? (
|
||||
<GovernancePanel
|
||||
projectPath={projectPath}
|
||||
projectName={projectName}
|
||||
onClose={() => setSidePanel(null)}
|
||||
onOpenFlywheel={() => setSidePanel({ type: "flywheel" })}
|
||||
/>
|
||||
) : sidePanel.type === "flywheel" ? (
|
||||
<FlywheelPanel onClose={() => setSidePanel(null)} />
|
||||
) : (
|
||||
<NextActionsPanel
|
||||
modules={blueprint?.manifest.modules ?? []}
|
||||
projectName={projectName}
|
||||
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
|
||||
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
|
||||
/>
|
||||
@ -747,13 +829,40 @@ function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: stri
|
||||
|
||||
function NextActionsPanel({
|
||||
modules,
|
||||
projectName,
|
||||
onSelectModule,
|
||||
onClose,
|
||||
}: {
|
||||
modules: BlueprintModule[];
|
||||
projectName: string;
|
||||
onSelectModule: (mod: BlueprintModule) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [batchCopied, setBatchCopied] = useState(false);
|
||||
|
||||
const handleCopyBatchPrompt = () => {
|
||||
const prompt = [
|
||||
`当前项目:${projectName}`,
|
||||
``,
|
||||
`读取 .blueprint/manifest.yaml 和所有 .blueprint/modules/*.md,`,
|
||||
`以及 .blueprint/CONVENTIONS.md 了解执行规则。`,
|
||||
``,
|
||||
`【处理断点】先检查是否有 🔵 in_progress 任务卡(上次中断的),`,
|
||||
`若有,读取其 files 字段判断已完成什么、还缺什么,从断点继续。`,
|
||||
``,
|
||||
`【批量执行】找出所有 📋 todo 任务卡,按任务卡 depends 字段及 manifest.yaml edges 综合拓扑排序后依次执行:`,
|
||||
`- 执行前:前缀改为 🔵(信息1·加锁)`,
|
||||
`- 执行依据:任务卡的 files 字段(涉及哪些文件)+ acceptance 字段(验收标准)`,
|
||||
`- 完成后:前缀改为 ✅(信息2·解锁);若模块所有任务卡均完成,模块 status 改为 done`,
|
||||
`- 更新 manifest.yaml 的 updated 日期`,
|
||||
`- 无法独立解决:标记 🔴 blocked + blocked_reason,跳过继续下一张`,
|
||||
``,
|
||||
`中途不需要等我确认,直接执行到底,全部完成后输出执行报告。`,
|
||||
].join("\n");
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setBatchCopied(true);
|
||||
setTimeout(() => setBatchCopied(false), 2000);
|
||||
};
|
||||
// 收集所有可派发和进行中的任务
|
||||
const actionItems = useMemo(() => {
|
||||
const items: { module: BlueprintModule; task: BlueprintTask; dispatchable: boolean }[] = [];
|
||||
@ -784,7 +893,16 @@ function NextActionsPanel({
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">下一步</h3>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopyBatchPrompt}
|
||||
className="text-[11px] px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
||||
title="复制批量执行提示词,粘贴给 Claude 自动完成所有 📋 任务卡"
|
||||
>
|
||||
{batchCopied ? "已复制 ✓" : "🚀 批量执行"}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionItems.length === 0 ? (
|
||||
@ -877,6 +995,225 @@ function NextActionsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// ── 蓝图治理面板 ────────────────────────────────────────────────────────────
|
||||
|
||||
function GovernancePanel({
|
||||
projectPath,
|
||||
projectName,
|
||||
onClose,
|
||||
onOpenFlywheel,
|
||||
}: {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
onOpenFlywheel?: () => void;
|
||||
}) {
|
||||
const { data: bpStatus, isPending: statusLoading, refetch: refetchStatus } = useQuery<BlueprintStatus>({
|
||||
queryKey: ["bp-status-governance", projectPath],
|
||||
queryFn: () => getBlueprintStatus(projectPath),
|
||||
});
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
|
||||
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 handleSyncRules = async () => {
|
||||
setSyncing(true);
|
||||
setSyncError(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await syncBlueprintRules(projectPath);
|
||||
setSyncResult(res);
|
||||
refetchStatus();
|
||||
} catch (e) {
|
||||
setSyncError(String(e));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPrompt = async (mode: "init" | "sync") => {
|
||||
if (promptMode === mode) {
|
||||
setPromptMode(null);
|
||||
return;
|
||||
}
|
||||
setPromptMode(mode);
|
||||
setPrompt("");
|
||||
setPromptError(null);
|
||||
setPromptLoading(true);
|
||||
try {
|
||||
const result = await generateBlueprintPrompt(projectPath, mode);
|
||||
setPrompt(result);
|
||||
} catch (e) {
|
||||
setPromptError(String(e));
|
||||
} finally {
|
||||
setPromptLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const rulesOutdated = bpStatus?.status === "rules_outdated";
|
||||
const currentVersion = bpStatus?.conventions_version ?? "—";
|
||||
const masterVersion = bpStatus?.master_version ?? "—";
|
||||
// 用后端完整状态判断(rules_outdated 才是真正未同步,content_stale/synced 都意味着规则已同步)
|
||||
const versionSynced = bpStatus?.status === "synced" || bpStatus?.status === "content_stale";
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">蓝图治理</h3>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
|
||||
</div>
|
||||
|
||||
{/* 规则版本状态 */}
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">CONVENTIONS 规则版本</p>
|
||||
{statusLoading ? (
|
||||
<p className="text-xs text-gray-400">检测中…</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">当前版本</span>
|
||||
<span className="text-xs font-mono text-gray-700">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">最新版本</span>
|
||||
<span className="text-xs font-mono text-gray-700">{masterVersion}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">状态</span>
|
||||
{versionSynced ? (
|
||||
<span className="text-xs text-green-600 bg-green-50 px-1.5 py-0.5 rounded">🟢 已同步</span>
|
||||
) : (
|
||||
<span className="text-xs text-yellow-600 bg-yellow-50 px-1.5 py-0.5 rounded">🟡 待更新</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 同步规则区域 */}
|
||||
{rulesOutdated && !syncResult && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-yellow-800 font-semibold">规则版本待更新</p>
|
||||
<p className="text-[11px] text-yellow-700 leading-relaxed">
|
||||
将覆盖写入 <code className="font-mono bg-yellow-100 px-1 rounded">.blueprint/CONVENTIONS.md</code> 并更新 CLAUDE.md 中的蓝图规则区块。
|
||||
</p>
|
||||
{syncError && (
|
||||
<p className="text-[11px] text-red-600 font-mono break-all">{syncError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSyncRules}
|
||||
disabled={syncing}
|
||||
className="w-full py-1.5 rounded-lg bg-yellow-500 text-white text-xs hover:bg-yellow-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{syncing ? "同步中…" : "同步规则"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncResult && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p className="text-xs text-green-700 font-semibold mb-1">✓ 同步完成</p>
|
||||
<p className="text-[11px] text-green-600 font-mono">{syncResult.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 蓝图内容提示词 */}
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">蓝图内容</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
将提示词交给 Claude Opus,由它负责理解和创作蓝图内容。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenPrompt("init")}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
promptMode === "init"
|
||||
? "bg-blue-600 text-white"
|
||||
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
初始化蓝图
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenPrompt("sync")}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
promptMode === "sync"
|
||||
? "bg-blue-600 text-white"
|
||||
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
更新蓝图描绘
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示词内容区 */}
|
||||
{promptMode && (
|
||||
<div className="space-y-2">
|
||||
{promptLoading ? (
|
||||
<p className="text-xs text-gray-400 text-center py-4">生成提示词中…</p>
|
||||
) : promptError ? (
|
||||
<p className="text-xs text-red-500 font-mono break-all">{promptError}</p>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
readOnly
|
||||
value={prompt}
|
||||
rows={12}
|
||||
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2.5 resize-none outline-none focus:ring-2 focus:ring-blue-200"
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full py-1.5 rounded-lg bg-blue-600 text-white text-xs hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{copied ? "已复制 ✓" : "复制提示词"}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 text-center">点击文本框可全选</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 梦核分析入口 */}
|
||||
{onOpenFlywheel && (
|
||||
<div className="bg-emerald-50 rounded-lg border border-emerald-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-emerald-600 uppercase tracking-wider">梦核 · 记忆巩固</p>
|
||||
<p className="text-[11px] text-emerald-700 leading-relaxed">
|
||||
基于跨项目飞轮数据,生成梦核提示词,由 Claude Opus 完成固化 / 遗忘判断。
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenFlywheel}
|
||||
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
♻️ 打开飞轮面板
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 项目信息 */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-300 truncate">{projectName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 未完成任务面板 ──────────────────────────────────────────────────────────
|
||||
|
||||
const REMAINING_ORDER: Record<string, number> = { blocked: 0, in_progress: 1, todo: 2, concept: 3 };
|
||||
@ -947,3 +1284,334 @@ function RemainingTasksPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 飞轮健康面板 ────────────────────────────────────────────────────────────
|
||||
|
||||
function StalledProjectRow({ p, projectsMap, onArchive }: {
|
||||
p: StalledProject;
|
||||
projectsMap: Record<string, string>; // name → path
|
||||
onArchive?: (path: string, name: string) => void;
|
||||
}) {
|
||||
const path = projectsMap[p.name];
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-amber-700 truncate">· {p.name}</p>
|
||||
<p className="text-[10px] text-amber-400">最后活跃: {p.last_active}</p>
|
||||
</div>
|
||||
{onArchive && path && (
|
||||
<button
|
||||
onClick={() => onArchive(path, p.name)}
|
||||
className="text-[10px] text-amber-600 bg-amber-100 hover:bg-amber-200 px-1.5 py-0.5 rounded shrink-0 transition-colors"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlywheelPanel({ onClose }: { onClose: () => void }) {
|
||||
const { data: projects } = useQuery<Project[]>({
|
||||
queryKey: ["projects-for-flywheel"],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
|
||||
const { projectPaths, projectsMap } = useMemo(() => {
|
||||
const paths = (projects ?? [])
|
||||
.map((p) => p.win_path || p.wsl_path || "")
|
||||
.filter(Boolean);
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of (projects ?? [])) {
|
||||
const path = p.win_path || p.wsl_path || "";
|
||||
if (!path) continue;
|
||||
// 用炼境项目名做 key
|
||||
map[p.name] = path;
|
||||
// 同时用路径末尾目录名做备用 key(usage.json 里存的是目录名)
|
||||
const dirName = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
|
||||
if (dirName && !map[dirName]) map[dirName] = path;
|
||||
}
|
||||
return { projectPaths: paths, projectsMap: map };
|
||||
}, [projects]);
|
||||
|
||||
const { data: stats, isLoading } = useQuery<FlywheelStats>({
|
||||
queryKey: ["flywheel-stats", projectPaths],
|
||||
queryFn: () => getFlywheelStats(projectPaths),
|
||||
enabled: projectPaths.length > 0,
|
||||
});
|
||||
|
||||
const [muhePrompt, setMuhePrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [archiveMsg, setArchiveMsg] = useState<string | null>(null);
|
||||
const [archiving, setArchiving] = useState<string | null>(null);
|
||||
const [pendingArchive, setPendingArchive] = useState<{ path: string; name: string } | null>(null);
|
||||
|
||||
const handleGenerateMuhe = async () => {
|
||||
if (!stats) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await generateMuhePrompt(stats);
|
||||
setMuhePrompt(result);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(muhePrompt).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveConfirm = async () => {
|
||||
if (!pendingArchive) return;
|
||||
const { path, name } = pendingArchive;
|
||||
setPendingArchive(null);
|
||||
setArchiving(name);
|
||||
setArchiveMsg(null);
|
||||
try {
|
||||
const result = await archiveProjectDocs(path);
|
||||
setArchiveMsg(`✓ 已归档 ${result.archived_files.length} 个文件 → ${result.archive_dir}`);
|
||||
} catch (e) {
|
||||
setArchiveMsg(`归档失败: ${String(e)}`);
|
||||
} finally {
|
||||
setArchiving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fmtPct = (v: number) => `${(v * 100).toFixed(1)}%`;
|
||||
|
||||
// Fix 2:去重——90天休眠的项目不在60天停转里重复展示
|
||||
const stalledOnlyProjects = (stats?.stalled_projects ?? []).filter(
|
||||
(p) => !(stats?.ai_doc_stale_projects ?? []).some((q) => q.name === p.name)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">飞轮健康</h3>
|
||||
{stats && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{stats.projects_with_data}/{stats.total_projects_scanned} 个项目有数据
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-gray-400 text-center py-6">聚合中…</p>
|
||||
) : !stats || stats.projects_with_data === 0 ? (
|
||||
<div className="text-center py-6 space-y-2">
|
||||
<p className="text-xs text-gray-400">暂无飞轮数据</p>
|
||||
<p className="text-[11px] text-gray-300">打开各项目蓝图后数据将自动积累</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* CONVENTIONS 版本队列对比 */}
|
||||
{stats.cohorts.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">版本队列对比</p>
|
||||
<div className="space-y-3">
|
||||
{stats.cohorts.map((c) => (
|
||||
<div key={c.conventions_version} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-gray-600">v{c.conventions_version}</span>
|
||||
<span className="text-[10px] text-gray-400">{c.project_count} 个项目</span>
|
||||
</div>
|
||||
{/* Fix 4:标签改为"任务完成率",字段改为 completion_rate */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
<MetricRow label="可派发率" value={fmtPct(c.dispatchable_rate)} good={c.dispatchable_rate > 0.3} />
|
||||
<MetricRow label="任务完成率" value={fmtPct(c.completion_rate)} good={c.completion_rate > 0.5} />
|
||||
<MetricRow label="飞轮持续率" value={fmtPct(c.continuity_rate)} good={c.continuity_rate > 0.6} />
|
||||
<MetricRow label="阻塞密度" value={fmtPct(c.blocked_density)} good={c.blocked_density < 0.1} invert />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TOP blocked reasons */}
|
||||
{stats.top_blocked_reasons.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">TOP 阻塞原因</p>
|
||||
<ul className="space-y-1">
|
||||
{stats.top_blocked_reasons.map((r, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">#{i + 1}</span>
|
||||
<span className="text-[11px] text-red-600 flex-1">{r.reason}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">×{r.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix 2:仅展示"停转但未到90天"的项目 */}
|
||||
{stalledOnlyProjects.length > 0 && (
|
||||
<div className="bg-amber-50 rounded-lg border border-amber-100 p-3 space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-amber-500 uppercase tracking-wider">
|
||||
飞轮停转(60–90天无快照)
|
||||
</p>
|
||||
{stalledOnlyProjects.map((p, i) => (
|
||||
<StalledProjectRow key={i} p={p} projectsMap={projectsMap} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI文档休眠项目(≥90天) */}
|
||||
{stats.ai_doc_stale_projects.length > 0 && (
|
||||
<div className="bg-red-50 rounded-lg border border-red-100 p-3 space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-red-500 uppercase tracking-wider">
|
||||
AI文档休眠(90天+无快照)
|
||||
</p>
|
||||
<p className="text-[10px] text-red-400 mb-1">
|
||||
建议归档 .blueprint/modules/,清空 Claude 上下文负担。
|
||||
</p>
|
||||
{/* Fix 1:归档确认弹窗 */}
|
||||
{pendingArchive ? (
|
||||
<div className="bg-red-100 border border-red-200 rounded p-2.5 space-y-2">
|
||||
<p className="text-[11px] text-red-700 font-semibold">
|
||||
确认归档「{pendingArchive.name}」?
|
||||
</p>
|
||||
<p className="text-[10px] text-red-500 leading-relaxed">
|
||||
将删除 .blueprint/modules/*.md,文件会备份到 .blueprint/archive/,但操作本身不可一键撤销。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleArchiveConfirm}
|
||||
disabled={!!archiving}
|
||||
className="flex-1 py-1 rounded bg-red-600 text-white text-[11px] hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
确认归档
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingArchive(null)}
|
||||
className="flex-1 py-1 rounded border border-red-200 text-red-500 text-[11px] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
stats.ai_doc_stale_projects.map((p, i) => (
|
||||
<StalledProjectRow
|
||||
key={i}
|
||||
p={p}
|
||||
projectsMap={projectsMap}
|
||||
onArchive={archiving ? undefined : (path, name) => setPendingArchive({ path, name })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{archiving && (
|
||||
<p className="text-[10px] text-red-400">归档 {archiving} 中…</p>
|
||||
)}
|
||||
{archiveMsg && (
|
||||
<p className={`text-[10px] break-all ${archiveMsg.startsWith("✓") ? "text-green-600" : "text-red-600"}`}>
|
||||
{archiveMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 迭代跳变项目 */}
|
||||
{(stats.iteration_jumps ?? []).length > 0 && (
|
||||
<div className="bg-purple-50 rounded-lg border border-purple-100 p-3 space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-purple-500 uppercase tracking-wider">
|
||||
迭代跳变(主动重构,完成率重置)
|
||||
</p>
|
||||
{(stats.iteration_jumps ?? []).map((j, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="text-gray-600 truncate flex-1">{j.project}</span>
|
||||
<span className="text-purple-400 shrink-0">迭代 {j.from_iteration}→{j.to_iteration}</span>
|
||||
<span className={`shrink-0 font-mono ${j.rate_after < j.rate_before - 0.3 ? "text-amber-500 font-semibold" : "text-gray-400"}`}>
|
||||
{(j.rate_before * 100).toFixed(0)}%→{(j.rate_after * 100).toFixed(0)}%
|
||||
{j.rate_after < j.rate_before - 0.3 && " ⚠️"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 停滞模块 */}
|
||||
{(stats.stalled_modules ?? []).length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3 space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
停滞模块(in_progress 但无可推进任务)
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{(stats.stalled_modules ?? []).map((m, i) => (
|
||||
<li key={i} className="text-[11px] text-gray-500 font-mono">{m}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 梦核分析 */}
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">梦核分析</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
生成包含以上数据的提示词,交给 Claude Opus 做固化/遗忘判断。
|
||||
</p>
|
||||
{!muhePrompt ? (
|
||||
<button
|
||||
onClick={handleGenerateMuhe}
|
||||
disabled={generating}
|
||||
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{generating ? "生成中…" : "生成梦核提示词"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
readOnly
|
||||
value={muhePrompt}
|
||||
rows={10}
|
||||
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2.5 resize-none outline-none"
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{copied ? "已复制 ✓" : "复制提示词"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMuhePrompt("")}
|
||||
className="w-full py-1 rounded-lg text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
重新生成
|
||||
</button>
|
||||
{/* Fix 3:说清楚实际流程——梦核不自动写入,需手动更新源码再发版 */}
|
||||
<p className="text-[10px] text-gray-400 text-center leading-relaxed">
|
||||
复制 → 粘贴给 Claude Opus → 将建议反馈给炼境维护者 → 更新 CONVENTIONS.md 并发布新版本
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({ label, value, good, invert = false }: {
|
||||
label: string;
|
||||
value: string;
|
||||
good: boolean;
|
||||
invert?: boolean;
|
||||
}) {
|
||||
const isGood = invert ? !good : good;
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-gray-400">{label}</span>
|
||||
<span className={`text-[10px] font-mono font-semibold ${isGood ? "text-green-600" : "text-yellow-600"}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
229
src/components/dashboard/BlueprintPromptModal.tsx
Normal file
229
src/components/dashboard/BlueprintPromptModal.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { generateBlueprintPrompt, syncBlueprintRules, type BlueprintStatus, type SyncResult } from "../../lib/commands";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
bpStatus: BlueprintStatus["status"];
|
||||
onClose: () => void;
|
||||
onSynced?: () => void; // 同步完成后通知父组件刷新状态
|
||||
}
|
||||
|
||||
// ── 规则同步确认模式(rules_outdated)────────────────────────────────────────
|
||||
|
||||
function SyncConfirmView({
|
||||
projectName, projectPath, onClose, onSynced,
|
||||
}: {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
onClose: () => void;
|
||||
onSynced?: () => void;
|
||||
}) {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [result, setResult] = useState<SyncResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await syncBlueprintRules(projectPath);
|
||||
setResult(res);
|
||||
onSynced?.();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">规则版本待更新</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{projectName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="px-5 py-4 flex flex-col gap-4">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-xs text-yellow-800 space-y-1.5">
|
||||
<p className="font-semibold">将执行以下操作:</p>
|
||||
<ul className="space-y-1 pl-3 list-disc">
|
||||
<li>覆盖写入 <code className="font-mono bg-yellow-100 px-1 rounded">.blueprint/CONVENTIONS.md</code>(升级至最新版本)</li>
|
||||
<li>更新 <code className="font-mono bg-yellow-100 px-1 rounded">CLAUDE.md</code> 中的蓝图规则区块</li>
|
||||
</ul>
|
||||
<p className="text-yellow-600 pt-1">不会影响项目的其他文件和蓝图内容。</p>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-xs text-green-800">
|
||||
<p className="font-semibold mb-1">✓ 同步完成</p>
|
||||
<p className="font-mono text-green-700">{result.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs text-red-700 font-mono break-all">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="px-5 py-3 border-t border-gray-100 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{result ? "关闭" : "取消"}
|
||||
</button>
|
||||
{!result && (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="px-4 py-1.5 rounded-lg bg-yellow-500 text-white text-sm hover:bg-yellow-600 disabled:opacity-50 transition-colors min-w-[80px]"
|
||||
>
|
||||
{syncing ? "同步中…" : "确认同步"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 提示词生成模式(none / content_stale)────────────────────────────────────
|
||||
|
||||
function PromptView({
|
||||
projectName, projectPath, bpStatus, onClose,
|
||||
}: {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
bpStatus: "none" | "content_stale";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const promptType = bpStatus === "none" ? "init" : "sync";
|
||||
const title = bpStatus === "none" ? "初始化蓝图" : "同步蓝图描绘";
|
||||
const hint = bpStatus === "none"
|
||||
? "将以下提示词复制后粘贴给 Claude Opus,让它为项目创建蓝图"
|
||||
: "将以下提示词复制后粘贴给 Claude Opus,让它更新项目蓝图";
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
generateBlueprintPrompt(projectPath, promptType)
|
||||
.then(setPrompt)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectPath, promptType]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{projectName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="px-5 py-3 bg-amber-50 border-b border-amber-100">
|
||||
<p className="text-xs text-amber-700">
|
||||
🤖 {hint},由 Claude 负责理解和创作蓝图内容。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 提示词区域 */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col px-5 py-4 gap-3">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-gray-400">
|
||||
生成提示词中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-red-500 font-mono break-all">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readOnly
|
||||
value={prompt}
|
||||
className="flex-1 font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-3 resize-none outline-none focus:ring-2 focus:ring-blue-200"
|
||||
onClick={() => textareaRef.current?.select()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<p className="text-xs text-gray-400">点击文本框可全选</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={loading || !!error}
|
||||
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50 transition-colors min-w-[80px]"
|
||||
>
|
||||
{copied ? "已复制 ✓" : "复制提示词"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主组件 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlueprintPromptModal({ projectName, projectPath, bpStatus, onClose, onSynced }: Props) {
|
||||
const handleBackdrop = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col h-[75vh]">
|
||||
{bpStatus === "rules_outdated" ? (
|
||||
<SyncConfirmView
|
||||
projectName={projectName}
|
||||
projectPath={projectPath}
|
||||
onClose={onClose}
|
||||
onSynced={onSynced}
|
||||
/>
|
||||
) : (
|
||||
<PromptView
|
||||
projectName={projectName}
|
||||
projectPath={projectPath}
|
||||
bpStatus={bpStatus as "none" | "content_stale"}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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, type Project, type Space, type ActionsRun } from "../../lib/commands";
|
||||
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 { useUIStore } from "../../store/ui";
|
||||
import { PriorityBadge, StatusBadge } from "../ui/Badge";
|
||||
import { ProjectToolsModal } from "./ProjectToolsModal";
|
||||
@ -8,9 +8,48 @@ import { ProjectEnvModal } from "./ProjectEnvModal";
|
||||
import { CicdModal } from "./CicdModal";
|
||||
import { PublishModal } from "./PublishModal";
|
||||
import { BlueprintModal } from "./BlueprintModal";
|
||||
import { BlueprintPromptModal } from "./BlueprintPromptModal";
|
||||
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
// ── 蓝图状态徽章 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const BP_BADGE: Record<BlueprintStatus["status"], { dot: string; label: string; title: string }> = {
|
||||
none: { dot: "bg-gray-300", label: "无蓝图", title: "项目尚未接入蓝图,点击获取初始化提示词" },
|
||||
rules_outdated: { dot: "bg-yellow-400", label: "规则待更新", title: "CONVENTIONS.md 版本落后,点击查看详情" },
|
||||
content_stale: { dot: "bg-amber-400", label: "内容待同步", title: "蓝图内容可能落后于代码,点击获取同步提示词" },
|
||||
synced: { dot: "bg-green-400", label: "已同步", title: "蓝图规则和内容均为最新" },
|
||||
};
|
||||
|
||||
function BlueprintStatusBadge({
|
||||
status, syncing, onOpenPrompt, onOpenViewer,
|
||||
}: {
|
||||
status: BlueprintStatus["status"];
|
||||
syncing: boolean;
|
||||
onOpenPrompt: () => void;
|
||||
onOpenViewer: () => void;
|
||||
}) {
|
||||
const cfg = BP_BADGE[status];
|
||||
|
||||
const handleClick = () => {
|
||||
if (status === "synced") { onOpenViewer(); return; }
|
||||
onOpenPrompt(); // none / rules_outdated / content_stale
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={syncing}
|
||||
title={cfg.title}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full border border-gray-100 bg-gray-50 hover:bg-gray-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${cfg.dot} shrink-0`} />
|
||||
<span className="text-[10px] text-gray-500">{syncing ? "同步中…" : cfg.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
role?: string;
|
||||
@ -24,6 +63,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
const [showCicd, setShowCicd] = useState(false);
|
||||
const [showPublish, setShowPublish] = useState(false);
|
||||
const [showBlueprint, setShowBlueprint] = useState(false);
|
||||
const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
|
||||
const [showBranchMenu, setShowBranchMenu] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [creatingBranch, setCreatingBranch] = useState(false);
|
||||
@ -145,6 +185,18 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 蓝图路径:优先 win_path;WSL 项目仅当 wsl_path 为 UNC 格式时可从 Windows 侧访问
|
||||
const blueprintPath = p.win_path
|
||||
|| (p.wsl_path?.startsWith("\\\\wsl.") ? p.wsl_path : undefined);
|
||||
|
||||
const { data: bpStatus, refetch: refetchBpStatus, isPending: bpPending } = useQuery<BlueprintStatus>({
|
||||
queryKey: ["blueprintStatus", p.id],
|
||||
queryFn: () => getBlueprintStatus(blueprintPath!),
|
||||
enabled: !!blueprintPath,
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// PR 链接:从 repo_url 解析出 GitHub 路径
|
||||
const prUrl = (() => {
|
||||
if (!p.repo_url || !gitStat) return null;
|
||||
@ -317,6 +369,15 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 蓝图状态徽章:路径存在且查询已完成(成功或失败均显示) */}
|
||||
{blueprintPath && !bpPending && (
|
||||
<BlueprintStatusBadge
|
||||
status={bpStatus?.status ?? "none"}
|
||||
syncing={false}
|
||||
onOpenPrompt={() => setShowBlueprintPrompt(true)}
|
||||
onOpenViewer={() => setShowBlueprint(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -696,11 +757,21 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
{showBlueprint && (p.win_path || p.wsl_path) && (
|
||||
<BlueprintModal
|
||||
projectName={p.name}
|
||||
projectPath={p.win_path ?? p.wsl_path ?? ""}
|
||||
projectPath={p.win_path || p.wsl_path || ""}
|
||||
onClose={() => setShowBlueprint(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBlueprintPrompt && blueprintPath && (
|
||||
<BlueprintPromptModal
|
||||
projectName={p.name}
|
||||
projectPath={blueprintPath}
|
||||
bpStatus={bpStatus?.status ?? "none"}
|
||||
onClose={() => setShowBlueprintPrompt(false)}
|
||||
onSynced={() => refetchBpStatus()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expanded activity */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-50 pt-3">
|
||||
|
||||
@ -59,7 +59,7 @@ export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
|
||||
|
||||
{/* Logo */}
|
||||
<div className="h-14 flex items-center px-4 border-b border-slate-700 shrink-0">
|
||||
<span className="font-bold text-white text-sm tracking-tight">Dev Manager</span>
|
||||
<span className="font-bold text-white text-sm tracking-tight">炼境</span>
|
||||
</div>
|
||||
|
||||
{/* 导航区(随当前空间) */}
|
||||
|
||||
@ -295,9 +295,37 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
||||
<div className="px-5 py-4 flex flex-wrap gap-2">
|
||||
{members.map((m) => {
|
||||
const p = projects.find((x) => x.id === m.project_id);
|
||||
const hasPath = !!(p?.win_path || p?.wsl_path);
|
||||
return p ? (
|
||||
<div key={m.project_id} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-50 border border-gray-200 text-xs">
|
||||
<span className="font-medium text-gray-700">{p.name}</span>
|
||||
{/* 平台标识 */}
|
||||
{p.platform && (
|
||||
<span className={`px-1.5 py-0.5 rounded border text-xs ${
|
||||
p.platform === "wsl"
|
||||
? "bg-orange-50 text-orange-600 border-orange-200"
|
||||
: "bg-blue-50 text-blue-600 border-blue-200"
|
||||
}`}>
|
||||
{p.platform === "wsl" ? "WSL" : "Win"}
|
||||
</span>
|
||||
)}
|
||||
{/* MCP 注入状态 */}
|
||||
{hasPath ? (
|
||||
<span
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded border bg-green-50 text-green-600 border-green-200"
|
||||
title={`MCP 已注入:${p.win_path || p.wsl_path}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
MCP
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded border bg-gray-100 text-gray-400 border-gray-200"
|
||||
title="项目未配置路径,MCP 注入已跳过"
|
||||
>
|
||||
无路径
|
||||
</span>
|
||||
)}
|
||||
{m.role && <span className="text-gray-400 border-l border-gray-200 pl-2">{m.role}</span>}
|
||||
<button
|
||||
onClick={async () => {
|
||||
|
||||
@ -6,7 +6,8 @@ import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import {
|
||||
detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros,
|
||||
type EditorCandidate,
|
||||
getProjects, syncBlueprintRules,
|
||||
type EditorCandidate, type SyncResult,
|
||||
} from "../../lib/commands";
|
||||
// GitHub OAuth 配置已移至侧边栏「应用设置」
|
||||
import { useUIStore } from "../../store/ui";
|
||||
@ -191,6 +192,7 @@ export function SettingsPage() {
|
||||
const [detectingWsl, setDetectingWsl] = useState(false);
|
||||
const [wslError, setWslError] = useState("");
|
||||
const [formTarget, setFormTarget] = useState<EditorEntry | null | undefined>(undefined);
|
||||
const [mcpPort, setMcpPort] = useState("27190");
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@ -204,6 +206,7 @@ export function SettingsPage() {
|
||||
}
|
||||
setWslDistro(settings.wsl_distro ?? "Ubuntu");
|
||||
setWslOpenMode(settings.wsl_open_mode ?? "wsl_internal");
|
||||
setMcpPort(settings.mcp_port ?? "27190");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@ -252,6 +255,17 @@ export function SettingsPage() {
|
||||
showToast("已保存 ✓");
|
||||
};
|
||||
|
||||
const saveMcp = async () => {
|
||||
const port = parseInt(mcpPort, 10);
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
showToast("❌ 端口范围:1024 ~ 65535");
|
||||
return;
|
||||
}
|
||||
await updateSettings({ mcp_port: mcpPort });
|
||||
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
|
||||
showToast("已保存,所有产品组成员配置已更新 ✓");
|
||||
};
|
||||
|
||||
const detectWsl = async () => {
|
||||
setDetectingWsl(true);
|
||||
setWslError("");
|
||||
@ -455,6 +469,47 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── MCP 服务 ── */}
|
||||
<section className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-gray-700">MCP 服务</h2>
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs bg-green-50 text-green-600 border border-green-200">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
运行中
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
产品组 MCP Server,为组内项目的 Claude 提供跨项目上下文能力。配置好产品组成员后,每个项目的
|
||||
{" "}<code className="font-mono bg-gray-50 px-1 rounded">.claude/settings.json</code>{" "}
|
||||
会自动注入对应的 MCP 连接。
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">监听端口</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="w-28 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-400"
|
||||
value={mcpPort}
|
||||
onChange={(e) => setMcpPort(e.target.value)}
|
||||
min={1024}
|
||||
max={65535}
|
||||
/>
|
||||
<button
|
||||
onClick={saveMcp}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1.5">
|
||||
产品组 MCP 地址:
|
||||
<code className="font-mono bg-gray-50 px-1 rounded">
|
||||
http://localhost:{mcpPort}/mcp/group/<group_id>
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 编辑器表单弹窗 */}
|
||||
{formTarget !== undefined && (
|
||||
<EditorFormModal
|
||||
@ -464,11 +519,124 @@ export function SettingsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<BatchSyncSection />
|
||||
<UpdateSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 蓝图规则批量同步区块 ────────────────────────────────────────────────────── */
|
||||
|
||||
interface SyncRow {
|
||||
name: string;
|
||||
path: string;
|
||||
status: "pending" | "ok" | "error";
|
||||
result?: SyncResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function BatchSyncSection() {
|
||||
const [rows, setRows] = useState<SyncRow[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const handleSync = async () => {
|
||||
setRunning(true);
|
||||
setDone(false);
|
||||
|
||||
let projects: Awaited<ReturnType<typeof getProjects>> = [];
|
||||
try {
|
||||
projects = await getProjects(undefined, undefined);
|
||||
} catch {
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 只处理有 win_path 的项目
|
||||
const targets = projects
|
||||
.filter((p) => p.win_path)
|
||||
.map((p) => ({ name: p.name, path: p.win_path! }));
|
||||
|
||||
if (targets.length === 0) {
|
||||
setRows([]);
|
||||
setRunning(false);
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const initial: SyncRow[] = targets.map((t) => ({ ...t, status: "pending" }));
|
||||
setRows(initial);
|
||||
|
||||
const results = [...initial];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
try {
|
||||
const result = await syncBlueprintRules(results[i].path);
|
||||
results[i] = { ...results[i], status: "ok", result };
|
||||
} catch (e) {
|
||||
results[i] = { ...results[i], status: "error", error: String(e) };
|
||||
}
|
||||
setRows([...results]);
|
||||
}
|
||||
|
||||
setRunning(false);
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl border border-gray-200 shadow-sm px-6 py-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-700">蓝图规则同步</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
将最新 CONVENTIONS.md 同步至所有已注册项目
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={running}
|
||||
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{running ? "同步中…" : "同步所有项目"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.path}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||
row.status === "ok"
|
||||
? "bg-green-50 text-green-800"
|
||||
: row.status === "error"
|
||||
? "bg-red-50 text-red-700"
|
||||
: "bg-gray-50 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{row.status === "ok" ? "✓" : row.status === "error" ? "✗" : "…"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{row.name}</span>
|
||||
{row.status === "ok" && row.result && (
|
||||
<span className="ml-2 opacity-70">{row.result.message}</span>
|
||||
)}
|
||||
{row.status === "error" && (
|
||||
<span className="ml-2 font-mono break-all">{row.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && rows.length === 0 && (
|
||||
<p className="text-xs text-gray-400">没有找到包含 win_path 的项目。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 检查更新区块 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
type UpdateState =
|
||||
@ -526,7 +694,7 @@ function UpdateSection() {
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-4">关于 & 更新</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">Dev Manager</p>
|
||||
<p className="text-sm text-gray-700">炼境</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">当前版本 v{appVersion}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@ -908,6 +908,8 @@ export interface BlueprintData {
|
||||
name: string;
|
||||
description?: string;
|
||||
updated?: string;
|
||||
/** 当前迭代号,默认 1,重构已完成模块时递增 */
|
||||
iteration?: number;
|
||||
areas: BlueprintArea[];
|
||||
modules: BlueprintModule[];
|
||||
edges: BlueprintEdge[];
|
||||
@ -918,6 +920,93 @@ export interface BlueprintData {
|
||||
export const getBlueprint = (projectPath: string) =>
|
||||
invoke<BlueprintData | null>("get_blueprint", { projectPath });
|
||||
|
||||
export const generateBlueprintPrompt = (projectPath: string, promptType: "init" | "sync") =>
|
||||
invoke<string>("generate_blueprint_prompt", { projectPath, promptType });
|
||||
|
||||
export interface BlueprintStatus {
|
||||
status: "none" | "rules_outdated" | "content_stale" | "synced";
|
||||
conventions_version?: string;
|
||||
claude_md_managed: boolean;
|
||||
claude_md_version?: string;
|
||||
manifest_updated?: string;
|
||||
master_version: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
conventions_written: boolean;
|
||||
claude_md_action: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const getBlueprintStatus = (projectPath: string) =>
|
||||
invoke<BlueprintStatus>("get_blueprint_status", { projectPath });
|
||||
|
||||
export const syncBlueprintRules = (projectPath: string) =>
|
||||
invoke<SyncResult>("sync_blueprint_rules", { projectPath });
|
||||
|
||||
export interface FlywheelCohort {
|
||||
conventions_version: string;
|
||||
project_count: number;
|
||||
/** 可派发率:dispatched(可派发数) / total tasks(最新快照均值) */
|
||||
dispatchable_rate: number;
|
||||
/** 任务完成率:done / total tasks(最新快照均值,反映整体进度) */
|
||||
completion_rate: number;
|
||||
/** 飞轮持续率:30天内有快照的项目比例 */
|
||||
continuity_rate: number;
|
||||
/** 阻塞密度:blocked / total tasks(最新快照均值) */
|
||||
blocked_density: number;
|
||||
}
|
||||
|
||||
export interface BlockedReasonCount {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface StalledProject {
|
||||
name: string;
|
||||
/** UTC 日期字符串 "YYYY-MM-DD",未知时为 "unknown" */
|
||||
last_active: string;
|
||||
}
|
||||
|
||||
export interface IterationJump {
|
||||
project: string;
|
||||
from_iteration: number;
|
||||
to_iteration: number;
|
||||
/** 跳变前最后一条快照的任务完成率 */
|
||||
rate_before: number;
|
||||
/** 跳变后第一条快照的任务完成率 */
|
||||
rate_after: number;
|
||||
}
|
||||
|
||||
export interface FlywheelStats {
|
||||
cohorts: FlywheelCohort[];
|
||||
top_blocked_reasons: BlockedReasonCount[];
|
||||
/** 飞轮停转项目(60天无快照) */
|
||||
stalled_projects: StalledProject[];
|
||||
/** AI文档休眠项目(90天无快照) */
|
||||
ai_doc_stale_projects: StalledProject[];
|
||||
/** 迭代跳变记录(iteration 递增,说明发生了主动重构) */
|
||||
iteration_jumps: IterationJump[];
|
||||
/** 停滞模块(in_progress 但无可推进任务,格式 "project/module_id") */
|
||||
stalled_modules: string[];
|
||||
total_projects_scanned: number;
|
||||
projects_with_data: number;
|
||||
}
|
||||
|
||||
export const getFlywheelStats = (projectPaths: string[]) =>
|
||||
invoke<FlywheelStats>("get_flywheel_stats", { projectPaths });
|
||||
|
||||
export const generateMuhePrompt = (stats: FlywheelStats) =>
|
||||
invoke<string>("generate_muhe_prompt", { stats });
|
||||
|
||||
export interface ArchiveResult {
|
||||
archived_files: string[];
|
||||
archive_dir: string;
|
||||
}
|
||||
|
||||
export const archiveProjectDocs = (projectPath: string) =>
|
||||
invoke<ArchiveResult>("archive_project_docs", { projectPath });
|
||||
|
||||
// ── Canvas State ───────────────────────────────────────────────────────────────
|
||||
export const getCanvasState = (groupId: string) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user