feat: 飞轮智能阶段二完成——MCP SSE 传输 + 复盘覆盖率 + usage.json 扩展采集
- MCP Server 新增 GET/SSE 端点,Claude Code 原生 MCP 打通 - 项目导师面板显示复盘笔记覆盖率统计与缺口提醒 - usage.json 快照新增 complexity_dist、rework_count 等扩展字段 - 独立项目自动创建产品组并注入 MCP - 新增 retro-catchup 技能用于批量补写历史复盘笔记 - 补全飞轮存量函数单元测试 - NewProjectModal 支持更灵活的模板参数配置
This commit is contained in:
parent
6162533c77
commit
8c06aa5e44
@ -6,7 +6,7 @@ description: >
|
||||
成为每个项目的贴身导师。炼境本身亦被纳入其中,
|
||||
形成可自我进化的开发智能闭环。
|
||||
iteration: 1
|
||||
updated: 2026-04-11
|
||||
updated: 2026-04-13
|
||||
|
||||
|
||||
|
||||
@ -276,7 +276,7 @@ modules:
|
||||
name: 飞轮智能成长
|
||||
area: backend
|
||||
status: in_progress
|
||||
progress: 33
|
||||
progress: 86
|
||||
position: [1750, 650]
|
||||
|
||||
edges:
|
||||
|
||||
@ -36,8 +36,24 @@
|
||||
|
||||
---
|
||||
|
||||
### 📋 补测试:飞轮存量函数安全网
|
||||
- status: todo
|
||||
### ✅ 独立项目自动创建产品组
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/commands/groups.rs, src-tauri/src/commands/buff.rs
|
||||
- acceptance: 对未加入任何产品组的蓝图项目,apply_blueprint_buff 时自动创建 "{name} (独立项目)" 产品组并注入 MCP,使 append_project_note 在该项目的 Claude 会话中可用
|
||||
|
||||
---
|
||||
|
||||
### ✅ 复盘笔记覆盖率可见性
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/mentor.rs, src/lib/commands.ts, src/components/mentor/MentorPanel.tsx
|
||||
- acceptance: 项目导师面板显示"已完成 M/L 任务数 / 复盘笔记数 / 缺口"三格统计;缺口 > 0 时显示琥珀色警告提示;mentor_markdown 同步写入覆盖率表格和补写提醒,让 Claude 每次打开报告时自动感知
|
||||
|
||||
---
|
||||
|
||||
### ✅ 补测试:飞轮存量函数安全网
|
||||
- status: done
|
||||
- complexity: M
|
||||
- depends: flywheel-intelligence(阶段二启动前完成,保障数据关键路径)
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
@ -47,11 +63,21 @@
|
||||
|
||||
---
|
||||
|
||||
### 阶段二:usage.json 扩展采集维度
|
||||
- status: todo
|
||||
### ✅ MCP Server 支持 SSE 传输
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- acceptance: `GET /mcp/group/:group_id` 返回 `text/event-stream`,按 MCP SSE 传输规范完成握手;Claude Code 会话启动时 `append_project_note` 等工具可在工具列表中原生出现;Windows 本地测试通过(curl -N GET 端点可见 SSE 事件流)
|
||||
|
||||
> 背景:Claude Code MCP 客户端用 GET 发起 SSE 握手,炼境目前只接受 POST,导致 405 → MCP 永久失效。加 GET/SSE 端点后,所有项目原生 MCP 打通,复盘笔记可直接通过 `append_project_note` 写入,飞轮数据链完整,无需任何绕路脚本。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 阶段二:usage.json 扩展采集维度
|
||||
- status: done
|
||||
- complexity: M
|
||||
- depends: flywheel-intelligence(阶段一完成后启动)
|
||||
- files: src-tauri/src/commands/blueprint_flywheel.rs
|
||||
- files: src-tauri/src/commands/blueprint.rs
|
||||
- acceptance: usage.json 快照新增 complexity_dist、rework_count、avg_rounds_M 字段;字段值从复盘笔记解析填充;梦核分析可输出"M 级任务实际轮数 vs 预估"对比
|
||||
|
||||
阶段二详细设计:
|
||||
|
||||
@ -903,6 +903,192 @@
|
||||
"todo": 0,
|
||||
"total": 140
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-04-12T00:16:09Z",
|
||||
"blocked_reasons": [],
|
||||
"conventions_version": "1.4.4",
|
||||
"delta": {
|
||||
"newly_blocked": [],
|
||||
"newly_done": [
|
||||
"flywheel-intelligence/MCP Server 支持 SSE 传输",
|
||||
"flywheel-intelligence/复盘笔记覆盖率可见性",
|
||||
"flywheel-intelligence/独立项目自动创建产品组",
|
||||
"flywheel-intelligence/补测试:飞轮存量函数安全网",
|
||||
"flywheel-intelligence/阶段一:复盘笔记接入梦核飞轮",
|
||||
"user-project-todos/前端:备忘清单 UI 组件",
|
||||
"user-project-todos/数据层:user_project_todos 表 + Rust commands"
|
||||
],
|
||||
"unblocked": []
|
||||
},
|
||||
"iteration": 1,
|
||||
"modules": {
|
||||
"blocked": 0,
|
||||
"concept": 0,
|
||||
"done": 35,
|
||||
"in_progress": 1,
|
||||
"planned": 0,
|
||||
"stalled": [
|
||||
"flywheel-intelligence"
|
||||
],
|
||||
"total": 36
|
||||
},
|
||||
"task_ids": {
|
||||
"blocked": [],
|
||||
"done": [
|
||||
"auto-update/Capabilities 权限配置",
|
||||
"auto-update/下载 & 安装",
|
||||
"auto-update/更新检测",
|
||||
"auto-update/自动检查更新",
|
||||
"auto-update/错误提示",
|
||||
"blueprint-buff/BlueprintView 跨项目切换",
|
||||
"blueprint-buff/Buff 生命周期 Rust 命令",
|
||||
"blueprint-buff/blueprint_buffs 数据表",
|
||||
"blueprint-buff/git watcher + 任务匹配写回 + 飞轮快照",
|
||||
"blueprint-buff/前端 Buff 管理 UI",
|
||||
"blueprint-buff/前端实时刷新",
|
||||
"blueprint-feedback/归档 command",
|
||||
"blueprint-feedback/归档候选展示",
|
||||
"blueprint-feedback/梦核分析入口(蓝图弹窗)",
|
||||
"blueprint-feedback/梦核提示词增加停滞模块数据",
|
||||
"blueprint-flywheel/get_flywheel_stats command",
|
||||
"blueprint-flywheel/get_flywheel_stats 支持迭代感知",
|
||||
"blueprint-flywheel/usage.json 埋点写入",
|
||||
"blueprint-flywheel/梦核提示词生成 command",
|
||||
"blueprint-flywheel/飞轮健康面板",
|
||||
"blueprint-governance/CLAUDE.md 模板定义",
|
||||
"blueprint-governance/CONVENTIONS.md 版本化",
|
||||
"blueprint-governance/GovernancePanel 规则编辑器 UI",
|
||||
"blueprint-governance/get_blueprint_status command",
|
||||
"blueprint-governance/sync/梦核/summary 切换为 effective 内容",
|
||||
"blueprint-governance/sync_blueprint_rules command",
|
||||
"blueprint-governance/批量同步所有项目规则",
|
||||
"blueprint-governance/用户版 CONVENTIONS 存储层初始化",
|
||||
"blueprint-governance/用户版 CONVENTIONS 读写 command",
|
||||
"blueprint-onboarding/generate_blueprint_prompt command",
|
||||
"blueprint-onboarding/蓝图操作弹窗",
|
||||
"blueprint-onboarding/项目列表蓝图状态徽章",
|
||||
"blueprint-reader/合并返回完整蓝图数据",
|
||||
"blueprint-reader/读取 manifest.yaml",
|
||||
"blueprint-reader/读取 modules/*.md",
|
||||
"blueprint-view/.blueprint/ 数据结构设计",
|
||||
"blueprint-view/React Flow 节点图渲染",
|
||||
"blueprint-view/blocked 状态支持",
|
||||
"blueprint-view/下一步汇总面板",
|
||||
"blueprint-view/任务卡复制派发",
|
||||
"blueprint-view/批量执行提示词入口",
|
||||
"blueprint-view/智能连线路由",
|
||||
"blueprint-view/模块详情面板",
|
||||
"blueprint-view/统计汇总条",
|
||||
"blueprint-view/蓝图治理面板",
|
||||
"blueprint-view/蓝图读取 Command",
|
||||
"blueprint-view/边高亮与模块聚焦",
|
||||
"blueprint-view/顶部状态行扩展",
|
||||
"blueprint-view/项目卡片蓝图入口",
|
||||
"cicd-workflow/Actions 运行历史",
|
||||
"cicd-workflow/CI/CD 配置 CRUD",
|
||||
"cicd-workflow/Tauri OSS 分发模板",
|
||||
"cicd-workflow/Web SSH 部署模板",
|
||||
"cicd-workflow/Workflow 文件生成",
|
||||
"devtools-scan/WSL 工具扫描",
|
||||
"devtools-scan/Windows 工具扫描",
|
||||
"devtools-scan/工具信息持久化",
|
||||
"flywheel-intelligence/MCP Server 支持 SSE 传输",
|
||||
"flywheel-intelligence/复盘笔记覆盖率可见性",
|
||||
"flywheel-intelligence/独立项目自动创建产品组",
|
||||
"flywheel-intelligence/补测试:飞轮存量函数安全网",
|
||||
"flywheel-intelligence/阶段一:复盘笔记接入梦核飞轮",
|
||||
"git-ops/上游状态检测",
|
||||
"git-ops/分支管理",
|
||||
"git-ops/基础操作",
|
||||
"github-auth/OAuth Device Flow",
|
||||
"github-auth/Token 管理",
|
||||
"github-publish/.gitignore 生成",
|
||||
"github-publish/Git 初始化 & 首次推送",
|
||||
"github-publish/创建 GitHub 仓库",
|
||||
"github-publish/扫描本地 Git 状态",
|
||||
"health-center/T1 · Backend 健康检查聚合命令",
|
||||
"health-center/T2 · 质量指标聚合",
|
||||
"health-center/T3 · 前端健康中心页面",
|
||||
"health-center/T4 · 扩展 MCP get_diagnostics",
|
||||
"mcp-config-inject/加入产品组时注入配置",
|
||||
"mcp-config-inject/端口变更时重新注入所有配置",
|
||||
"mcp-config-inject/退出产品组时清除配置",
|
||||
"mcp-server/MCP 端口可配置(Rust 侧)",
|
||||
"mcp-server/启动时绑定 HTTP 端口",
|
||||
"mcp-server/实现 MCP JSON-RPC 基础协议",
|
||||
"mcp-server/实现产品组上下文工具",
|
||||
"product-group-mcp/产品组详情显示 MCP 注入状态",
|
||||
"product-group-mcp/设置页添加 MCP 端口配置",
|
||||
"project-dashboard/分组画布",
|
||||
"project-dashboard/项目卡片列表",
|
||||
"project-dashboard/项目发现面板",
|
||||
"project-dashboard/项目快捷操作",
|
||||
"project-env-scan/环境信息持久化",
|
||||
"project-env-scan/项目环境检测",
|
||||
"project-mentor/T1 · 后端聚合 command(MentorContext)",
|
||||
"project-mentor/T10 · 启动健康扫描 + 前端 Alert Banner",
|
||||
"project-mentor/T11 · 项目历史快照",
|
||||
"project-mentor/T2 · MCP 项目导师查询接口",
|
||||
"project-mentor/T3 · 前端导师面板",
|
||||
"project-mentor/T4 · 炼境自指接入",
|
||||
"project-mentor/T5 · project_notes 数据层 + 后端命令",
|
||||
"project-mentor/T6 · MCP append_project_note 工具",
|
||||
"project-mentor/T7 · MentorPanel 展示 AI 笔记",
|
||||
"project-mentor/T8 · 后端全组巡检命令",
|
||||
"project-mentor/T9 · 前端全组巡检按钮",
|
||||
"project-mgmt/子文件夹扫描",
|
||||
"project-mgmt/批量导入项目",
|
||||
"project-mgmt/项目 CRUD",
|
||||
"project-mgmt/项目档案(Profile)",
|
||||
"project-templates/从模板创建项目",
|
||||
"project-templates/内置模板种植",
|
||||
"project-templates/模板 CRUD",
|
||||
"project-tools/工具 CRUD",
|
||||
"project-tools/工具启动",
|
||||
"repo-registry/仓库导入",
|
||||
"repo-registry/仓库挂载",
|
||||
"repo-registry/仓库注册表管理",
|
||||
"runtime-diagnostics/T1 · SQLite runtime_logs 表",
|
||||
"runtime-diagnostics/T2 · 替换 eprintln! 为结构化日志",
|
||||
"runtime-diagnostics/T3 · MCP Server 新增 get_diagnostics 工具",
|
||||
"runtime-diagnostics/T4 · 注入失败结果返回前端",
|
||||
"runtime-diagnostics/T5 · 设置页 MCP 真实健康检查",
|
||||
"settings-page/WSL 发行版选择",
|
||||
"settings-page/关于 & 更新",
|
||||
"settings-page/终端配置",
|
||||
"settings-page/编辑器检测",
|
||||
"shell-launch/打开终端",
|
||||
"shell-launch/打开编辑器",
|
||||
"sqlite-db/数据库迁移",
|
||||
"sqlite-db/连接池初始化",
|
||||
"tags-groups/分组管理",
|
||||
"tags-groups/标签管理",
|
||||
"tray-service/关闭窗口最小化到托盘",
|
||||
"tray-service/系统托盘图标与菜单",
|
||||
"tray-service/集成 tauri-plugin-single-instance",
|
||||
"user-project-todos/前端:备忘清单 UI 组件",
|
||||
"user-project-todos/数据层:user_project_todos 表 + Rust commands",
|
||||
"v2rayn-import/当前节点读取",
|
||||
"v2rayn-import/数据库路径检测",
|
||||
"v2rayn-import/节点列表扫描",
|
||||
"workspace-mgmt/空间 CRUD",
|
||||
"workspace-mgmt/空间级设置",
|
||||
"workspace-mgmt/部署命令管理",
|
||||
"wsl-proxy/代理一致性测试",
|
||||
"wsl-proxy/代理同步",
|
||||
"wsl-proxy/代理清除",
|
||||
"wsl-proxy/代理状态检测"
|
||||
]
|
||||
},
|
||||
"tasks": {
|
||||
"blocked": 0,
|
||||
"dispatched": 0,
|
||||
"done": 143,
|
||||
"in_progress": 0,
|
||||
"todo": 2,
|
||||
"total": 149
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
129
.claude/skills/retro-catchup/SKILL.md
Normal file
129
.claude/skills/retro-catchup/SKILL.md
Normal file
@ -0,0 +1,129 @@
|
||||
---
|
||||
name: retro-catchup
|
||||
description: 为项目中所有已完成但缺少复盘笔记的 M/L 任务卡补写历史复盘笔记,并通过 MCP append_project_note 写入炼境。Use when a project has completed M/L tasks but zero or few retrospective notes.
|
||||
allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *), Bash(python *), Bash(python3 *), mcp__lian*
|
||||
---
|
||||
|
||||
# 复盘笔记补写(Retro Catch-Up)
|
||||
|
||||
为已完成的 M/L 任务卡补写事后重建版复盘笔记,推动飞轮数据积累。
|
||||
|
||||
> ⚠️ 这些笔记是**事后重建**,基于任务卡内容 + git 历史推断,比实时记录精度低。但对识别高频模式仍然有价值。
|
||||
|
||||
## 写入通道(三级降级)
|
||||
|
||||
每次调用 `append_project_note` 时,按以下顺序尝试,成功即停止:
|
||||
|
||||
**① 原生 MCP**:若 `append_project_note` 工具可用,直接调用。
|
||||
|
||||
**② Python HTTP 直调**:若原生 MCP 不可用(Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/settings.json`,提取 group_id,用 Python 直接 POST 炼境 API:
|
||||
|
||||
```python
|
||||
import urllib.request, json, sys
|
||||
|
||||
# 从 .claude/settings.json 提取 group_id
|
||||
with open('.claude/settings.json') as f:
|
||||
cfg = json.load(f)
|
||||
group_id = None
|
||||
for key in cfg.get('mcpServers', {}):
|
||||
if key.startswith('lian-jing:group-'):
|
||||
group_id = key.split('lian-jing:group-')[1]
|
||||
break
|
||||
|
||||
# project_id 通过先调用 list_group_projects 获得
|
||||
url = f'http://localhost:27190/mcp/group/{group_id}'
|
||||
|
||||
def mcp_call(method, params={}):
|
||||
payload = json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
|
||||
req = urllib.request.Request(url, data=payload, method='POST')
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
req.add_header('Accept', 'application/json')
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read().decode('utf-8', errors='replace'))
|
||||
|
||||
# 调用示例
|
||||
result = mcp_call('tools/call', {
|
||||
'name': 'append_project_note',
|
||||
'arguments': {'project_id': PROJECT_ID, 'content': NOTE_CONTENT}
|
||||
})
|
||||
```
|
||||
|
||||
**③ 本地文件兜底**:若炼境未运行(连接超时),将所有笔记追加写入 `.blueprint/retro-notes.md`,格式与正式笔记相同,头部加注 `<!-- 待炼境恢复后手动同步 -->`。
|
||||
|
||||
## 步骤
|
||||
|
||||
### 1. 确认写入通道 + 项目 ID
|
||||
|
||||
按以下顺序确定:
|
||||
|
||||
1. 检测 `append_project_note` 是否在可用工具中 → 是则用**通道①**
|
||||
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen)→ 可达则用**通道②**,同时读取 `.claude/settings.json` 提取 `group_id`,再调用 `list_group_projects` 获取当前项目 `project_id`
|
||||
3. 均不可达 → 用**通道③**,无需 project_id
|
||||
|
||||
告知用户当前使用哪个通道,例如:`使用通道②(Python HTTP 直调)`。
|
||||
|
||||
### 2. 扫描所有已完成 M/L 任务卡
|
||||
|
||||
遍历 `.blueprint/modules/*.md`,收集满足以下条件的任务卡:
|
||||
- 前缀为 ✅(status: done)
|
||||
- complexity 为 `M` 或 `L`
|
||||
|
||||
输出扫描结果:共找到 N 张,列出标题 + 所属模块。
|
||||
|
||||
### 3. 逐卡重建复盘笔记
|
||||
|
||||
对每张任务卡按顺序处理:
|
||||
|
||||
**3a. 读取证据**
|
||||
- 读取任务卡的 `files` 字段列出的所有文件(当前代码状态)
|
||||
- 执行 `git log --oneline -- <file>` 查看相关文件的提交历史
|
||||
- 若提交数 > 1,执行 `git diff <first_commit>^..<last_commit> -- <file>` 了解变更量
|
||||
|
||||
**3b. 推断执行情况**
|
||||
根据证据推断:
|
||||
- **实际轮数**:相关文件的提交次数(粗估)
|
||||
- **是否返工**:若同一文件有多次修改且 message 含 fix/修复/重新/又 等关键词
|
||||
- **返工原因**:从 commit message 或代码变更中推断
|
||||
- **有效策略**:从最终实现方式中提取关键设计决策
|
||||
|
||||
**3c. 写入笔记**
|
||||
|
||||
按步骤 1 确定的通道调用 `append_project_note`,内容格式:
|
||||
|
||||
```
|
||||
【复盘·重建】<任务标题>
|
||||
任务类型: 后端命令 | 前端组件 | 数据层 | MCP工具 | 架构调整
|
||||
复杂度: M | L
|
||||
实际轮数: N轮(事后推断,基于 git 提交数)
|
||||
是否返工: 是/否/不确定
|
||||
返工原因: <若返工,从 commit message 推断>
|
||||
有效策略: <从最终实现中提取的关键做法>
|
||||
遗留风险: <若代码中有明显 TODO 或边界情况未处理>
|
||||
备注: 此笔记为事后重建,非实时记录
|
||||
```
|
||||
|
||||
每写完一张,告知用户"✅ 已写入:<任务标题>"。
|
||||
|
||||
### 4. 输出汇总
|
||||
|
||||
```
|
||||
## 复盘补写完成
|
||||
|
||||
共处理 N 张 M/L 任务卡:
|
||||
- ✅ 成功写入:X 张
|
||||
- ⏭️ 跳过(无足够证据):Y 张
|
||||
|
||||
飞轮状态:累计复盘笔记 Z 条
|
||||
```
|
||||
|
||||
## 跳过条件
|
||||
|
||||
以下情况跳过该任务卡,不强行写入:
|
||||
- `files` 字段为空,且 git 中找不到相关提交
|
||||
- 任务卡内容过于简单(S 级错标为 M 的情况)
|
||||
|
||||
## 原则
|
||||
|
||||
- 笔记头部必须标注 `【复盘·重建】` 与 `备注: 此笔记为事后重建`,与实时笔记区分
|
||||
- 宁可跳过,不要捏造。证据不足时注明"信息不足,无法推断"
|
||||
- 每张卡独立处理,写入失败不影响后续
|
||||
13
src-tauri/Cargo.lock
generated
13
src-tauri/Cargo.lock
generated
@ -885,6 +885,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures-core",
|
||||
"git2",
|
||||
"once_cell",
|
||||
"open",
|
||||
@ -904,6 +905,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"ureq",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@ -5188,6 +5190,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
|
||||
@ -37,6 +37,8 @@ tauri-plugin-process = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["net", "time"] }
|
||||
tokio-stream = "0.1"
|
||||
futures-core = "0.3"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
open = "5"
|
||||
base64 = "0.22"
|
||||
|
||||
@ -1388,6 +1388,18 @@ pub struct FlywheelStats {
|
||||
pub projects_with_data: u32,
|
||||
/// 跨项目复盘笔记(最近 50 条,按时间倒序)
|
||||
pub project_notes: Vec<ProjectNoteEntry>,
|
||||
/// 跨项目执行质量指标(从各项目 usage.json 最新快照聚合)
|
||||
pub execution_quality: ExecutionQuality,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ExecutionQuality {
|
||||
/// 跨项目任务复杂度分布
|
||||
pub complexity_dist: std::collections::HashMap<String, u32>,
|
||||
/// 跨项目总返工次数
|
||||
pub total_rework_count: u32,
|
||||
/// M 级任务平均实际轮数(所有项目加权平均)
|
||||
pub avg_rounds_m: Option<f64>,
|
||||
}
|
||||
|
||||
/// 内部:单项目最新快照的指标摘要
|
||||
@ -1401,6 +1413,12 @@ struct ProjectSnap {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, String> {
|
||||
// 去重:同一项目可能出现在多个产品组中,路径重复会导致指标重复计数
|
||||
let project_paths: Vec<String> = {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
project_paths.into_iter().filter(|p| seen.insert(p.clone())).collect()
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let thirty_days_ago = now - chrono::Duration::days(30);
|
||||
let sixty_days_ago = now - chrono::Duration::days(60);
|
||||
@ -1417,6 +1435,11 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
|
||||
let mut ai_doc_stale_projects: Vec<StalledProject> = Vec::new();
|
||||
let mut iteration_jumps: Vec<IterationJump> = Vec::new();
|
||||
let mut stalled_modules: Vec<String> = Vec::new();
|
||||
// 执行质量聚合
|
||||
let mut agg_complexity: std::collections::HashMap<String, u32> = Default::default();
|
||||
let mut agg_rework = 0u32;
|
||||
let mut agg_m_rounds_sum = 0.0f64;
|
||||
let mut agg_m_rounds_count = 0u32;
|
||||
|
||||
for path_str in &project_paths {
|
||||
total_scanned += 1;
|
||||
@ -1534,6 +1557,22 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行质量指标聚合(阶段二新增字段) ──────────────────────────────────
|
||||
if let Some(dist) = latest["tasks"]["complexity_dist"].as_object() {
|
||||
for (k, v) in dist {
|
||||
if let Some(n) = v.as_u64() {
|
||||
*agg_complexity.entry(k.clone()).or_insert(0) += n as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rc) = latest["tasks"]["rework_count"].as_u64() {
|
||||
agg_rework += rc as u32;
|
||||
}
|
||||
if let Some(ar) = latest["tasks"]["avg_rounds_M"].as_f64() {
|
||||
agg_m_rounds_sum += ar;
|
||||
agg_m_rounds_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 cohorts
|
||||
@ -1589,6 +1628,12 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
|
||||
|
||||
let project_notes = collect_flywheel_notes(&project_paths);
|
||||
|
||||
let avg_rounds_m_agg = if agg_m_rounds_count > 0 {
|
||||
Some(((agg_m_rounds_sum / agg_m_rounds_count as f64) * 10.0).round() / 10.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(FlywheelStats {
|
||||
cohorts,
|
||||
top_blocked_reasons,
|
||||
@ -1599,6 +1644,11 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
|
||||
total_projects_scanned: total_scanned,
|
||||
projects_with_data,
|
||||
project_notes,
|
||||
execution_quality: ExecutionQuality {
|
||||
complexity_dist: agg_complexity,
|
||||
total_rework_count: agg_rework,
|
||||
avg_rounds_m: avg_rounds_m_agg,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1607,11 +1657,19 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
|
||||
/// 再关联 project_notes(project_id = workspace_id),取最近 50 条。
|
||||
/// DB 访问失败时静默返回空列表,不影响主流程。
|
||||
fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
|
||||
let conn = match crate::db::pool().get() {
|
||||
let pool = match crate::db::try_pool() {
|
||||
Some(p) => p,
|
||||
None => return vec![],
|
||||
};
|
||||
let conn = match pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
collect_flywheel_notes_inner(&conn, project_paths)
|
||||
}
|
||||
|
||||
/// 可测试的内层实现,接受注入的连接(支持 in-memory DB)。
|
||||
fn collect_flywheel_notes_inner(conn: &rusqlite::Connection, project_paths: &[String]) -> Vec<ProjectNoteEntry> {
|
||||
let mut notes: Vec<ProjectNoteEntry> = Vec::new();
|
||||
|
||||
for path_str in project_paths {
|
||||
@ -1783,6 +1841,24 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
// ── 执行质量指标 ──────────────────────────────────────────────────────────
|
||||
let eq = &stats.execution_quality;
|
||||
let complexity_summary = {
|
||||
let s = eq.complexity_dist.get("S").copied().unwrap_or(0);
|
||||
let m = eq.complexity_dist.get("M").copied().unwrap_or(0);
|
||||
let l = eq.complexity_dist.get("L").copied().unwrap_or(0);
|
||||
if s + m + l == 0 {
|
||||
"(暂无复杂度标记数据)".to_string()
|
||||
} else {
|
||||
format!("S: {} 个 | M: {} 个 | L: {} 个", s, m, l)
|
||||
}
|
||||
};
|
||||
let rounds_summary = match eq.avg_rounds_m {
|
||||
Some(v) => format!("{:.1} 轮", v),
|
||||
None => "(暂无 M 级复盘数据)".to_string(),
|
||||
};
|
||||
let rework_summary = format!("{} 次", eq.total_rework_count);
|
||||
|
||||
// ── 复盘笔记 ──────────────────────────────────────────────────────────────
|
||||
let notes_summary = if stats.project_notes.is_empty() {
|
||||
"(暂无复盘笔记——Claude 尚未在执行 M/L 任务后写入笔记)".to_string()
|
||||
@ -1834,6 +1910,13 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
|
||||
### 停滞模块(in_progress 但无可推进任务,格式:项目名/模块id)
|
||||
{stalled_modules_summary}
|
||||
|
||||
### 执行质量指标(跨项目聚合,来自 usage.json 阶段二扩展)
|
||||
- 任务复杂度分布:{complexity_summary}
|
||||
- M 级任务平均实际轮数:{rounds_summary}
|
||||
- 跨项目总返工次数:{rework_summary}
|
||||
|
||||
> 上述指标从各项目复盘笔记解析并写入 usage.json,用于量化执行效率变化趋势。
|
||||
|
||||
### 复盘笔记(跨项目 Claude 执行记录,共 {notes_total} 条)
|
||||
|
||||
> 笔记由 Claude 在完成 M/L 任务后通过 MCP 写入,结构化格式为【复盘】开头。
|
||||
@ -1864,6 +1947,7 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
|
||||
2. 相同踩坑在多个项目重复出现了吗?根因是什么?
|
||||
3. 有哪些有效策略已经在多个任务中奏效,可以固化为规范?
|
||||
4. 有哪些遗留风险被多次提及,说明系统存在尚未解决的脆弱点?
|
||||
5. M 级任务的实际轮数 vs 预估轮数偏差如何?是否存在系统性低估?
|
||||
|
||||
输出格式:
|
||||
```
|
||||
@ -1907,6 +1991,9 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
|
||||
ai_stale_summary = ai_stale_summary,
|
||||
iteration_summary = iteration_summary,
|
||||
stalled_modules_summary = stalled_modules_summary,
|
||||
complexity_summary = complexity_summary,
|
||||
rounds_summary = rounds_summary,
|
||||
rework_summary = rework_summary,
|
||||
notes_total = notes_total,
|
||||
notes_summary = notes_summary,
|
||||
conventions_version = effective_conventions_version(),
|
||||
@ -1916,6 +2003,119 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
|
||||
|
||||
// ── 飞轮数据层:usage.json 快照 ──────────────────────────────────────────────
|
||||
|
||||
/// 从项目的 project_notes 解析返工次数和 M 级任务平均实际轮数。
|
||||
/// 返回 (rework_count, avg_rounds_m),avg_rounds_m 为 None 表示无 M 级复盘数据。
|
||||
fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option<f64>) {
|
||||
let project_path = match bp_dir.parent().and_then(|p| p.to_str()) {
|
||||
Some(p) => p.to_string(),
|
||||
None => return (0, None),
|
||||
};
|
||||
|
||||
let pool = match crate::db::try_pool() {
|
||||
Some(p) => p,
|
||||
None => return (0, None),
|
||||
};
|
||||
let conn = match pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (0, None),
|
||||
};
|
||||
|
||||
// 查找此项目的 workspace_id
|
||||
let workspace_id: String = match conn.query_row(
|
||||
"SELECT id FROM project_workspaces WHERE win_path = ?1 OR wsl_path = ?1 LIMIT 1",
|
||||
rusqlite::params![project_path],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return (0, None),
|
||||
};
|
||||
|
||||
// 读取所有复盘笔记
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT content FROM project_notes WHERE project_id = ?1",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (0, None),
|
||||
};
|
||||
|
||||
let rows = match stmt.query_map(rusqlite::params![workspace_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
}) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return (0, None),
|
||||
};
|
||||
|
||||
let mut rework_count = 0u32;
|
||||
let mut m_rounds: Vec<f64> = Vec::new();
|
||||
|
||||
for row in rows.filter_map(|r| r.ok()) {
|
||||
let content = row;
|
||||
// 只解析【复盘】格式的笔记
|
||||
if !content.contains("【复盘】") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 每条笔记只取首次出现的字段,避免格式异常导致重复计数
|
||||
let mut note_rework = false;
|
||||
let mut note_rounds: Option<f64> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// 解析"是否返工: 是"(仅取首次)
|
||||
if !note_rework
|
||||
&& (trimmed.starts_with("是否返工:") || trimmed.starts_with("是否返工:"))
|
||||
{
|
||||
let val = trimmed.splitn(2, |c| c == ':' || c == ':').nth(1).unwrap_or("").trim();
|
||||
if val == "是" {
|
||||
note_rework = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析"实际轮数: N轮"(仅取首次)
|
||||
if note_rounds.is_none()
|
||||
&& (trimmed.starts_with("实际轮数:") || trimmed.starts_with("实际轮数:"))
|
||||
{
|
||||
let val = trimmed.splitn(2, |c| c == ':' || c == ':').nth(1).unwrap_or("").trim();
|
||||
// 提取数字部分,如 "3轮(预估2轮)" → 3.0
|
||||
note_rounds = val.chars()
|
||||
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||
.collect::<String>()
|
||||
.parse()
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
if note_rework {
|
||||
rework_count += 1;
|
||||
}
|
||||
|
||||
// 仅对 M 级任务聚合轮数
|
||||
if let Some(r) = note_rounds {
|
||||
let is_m = content.lines().any(|l| {
|
||||
let t = l.trim();
|
||||
(t.starts_with("复杂度:") || t.starts_with("复杂度:"))
|
||||
&& t.splitn(2, |c| c == ':' || c == ':')
|
||||
.nth(1)
|
||||
.map(|v| v.trim() == "M")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if is_m {
|
||||
m_rounds.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let avg = if m_rounds.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let sum: f64 = m_rounds.iter().sum();
|
||||
Some((sum / m_rounds.len() as f64 * 10.0).round() / 10.0) // 保留一位小数
|
||||
};
|
||||
|
||||
(rework_count, avg)
|
||||
}
|
||||
|
||||
/// 向项目 .blueprint/usage.json 追加一条每日飞轮快照。
|
||||
/// 节流:同一 UTC 日期只追加一次;写入失败静默忽略(不影响主流程)。
|
||||
fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &BlueprintStats) {
|
||||
@ -1966,8 +2166,20 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
|
||||
let mut tasks_in_progress = 0u32;
|
||||
let mut tasks_todo = 0u32;
|
||||
let mut blocked_reasons: Vec<String> = Vec::new();
|
||||
let mut complexity_s = 0u32;
|
||||
let mut complexity_m = 0u32;
|
||||
let mut complexity_l = 0u32;
|
||||
for module in &manifest.modules {
|
||||
for task in &module.tasks {
|
||||
// 复杂度分布统计
|
||||
if let Some(ref c) = task.complexity {
|
||||
match c.to_uppercase().as_str() {
|
||||
"S" => complexity_s += 1,
|
||||
"M" => complexity_m += 1,
|
||||
"L" => complexity_l += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match task.status.as_str() {
|
||||
"in_progress" => tasks_in_progress += 1,
|
||||
"todo" => tasks_todo += 1,
|
||||
@ -1984,6 +2196,9 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
|
||||
}
|
||||
}
|
||||
|
||||
// 从 project_notes 解析返工次数和 M 级任务平均轮数
|
||||
let (rework_count, avg_rounds_m) = parse_notes_metrics(bp_dir);
|
||||
|
||||
// 停滞模块:in_progress 且任务全为 in_progress/blocked(没有 todo 可推进)
|
||||
let stalled_modules: Vec<String> = manifest.modules.iter()
|
||||
.filter(|m| m.status == "in_progress" && !m.tasks.is_empty())
|
||||
@ -2054,7 +2269,10 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
|
||||
"in_progress": tasks_in_progress,
|
||||
"blocked": stats.tasks_blocked,
|
||||
"todo": tasks_todo,
|
||||
"dispatched": stats.dispatchable
|
||||
"dispatched": stats.dispatchable,
|
||||
"complexity_dist": { "S": complexity_s, "M": complexity_m, "L": complexity_l },
|
||||
"rework_count": rework_count,
|
||||
"avg_rounds_M": avg_rounds_m
|
||||
},
|
||||
"blocked_reasons": blocked_reasons,
|
||||
// 当前任务状态快照,供下次 delta 计算使用
|
||||
@ -2158,3 +2376,181 @@ pub fn archive_project_docs(project_path: String) -> Result<ArchiveResult, Strin
|
||||
archive_dir: archive_dir.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── 测试 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
|
||||
/// 建立最小 schema 的 in-memory DB,仅包含 collect_flywheel_notes_inner 所需表。
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE project_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE project_workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
win_path TEXT,
|
||||
wsl_path TEXT
|
||||
);
|
||||
CREATE TABLE project_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
// ── collect_flywheel_notes_inner ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_collect_notes_win_path_match() {
|
||||
let conn = setup_db();
|
||||
conn.execute("INSERT INTO project_profiles VALUES ('p1', 'MyProject')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces VALUES ('ws1', 'p1', 'C:/repos/my-project', NULL)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_notes VALUES (NULL, 'ws1', '【复盘】win path test', '2026-01-01T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let notes = collect_flywheel_notes_inner(&conn, &["C:/repos/my-project".to_string()]);
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].project_name, "MyProject");
|
||||
assert!(notes[0].content.contains("win path test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_notes_wsl_path_match() {
|
||||
let conn = setup_db();
|
||||
conn.execute("INSERT INTO project_profiles VALUES ('p2', 'WslProject')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces VALUES ('ws2', 'p2', NULL, '/home/user/wsl-project')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_notes VALUES (NULL, 'ws2', '【复盘】wsl path test', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let notes = collect_flywheel_notes_inner(&conn, &["/home/user/wsl-project".to_string()]);
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].project_name, "WslProject");
|
||||
assert!(notes[0].content.contains("wsl path test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_notes_project_not_found() {
|
||||
let conn = setup_db();
|
||||
// 空库,路径不存在
|
||||
let notes = collect_flywheel_notes_inner(&conn, &["/nonexistent/path".to_string()]);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
// ── get_flywheel_stats ───────────────────────────────────────────────────
|
||||
|
||||
fn write_usage(dir: &std::path::Path, json: serde_json::Value) {
|
||||
let bp = dir.join(".blueprint");
|
||||
fs::create_dir_all(&bp).unwrap();
|
||||
fs::write(bp.join("usage.json"), serde_json::to_string_pretty(&json).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flywheel_stats_empty_paths() {
|
||||
let stats = get_flywheel_stats(vec![]).unwrap();
|
||||
assert_eq!(stats.total_projects_scanned, 0);
|
||||
assert_eq!(stats.projects_with_data, 0);
|
||||
assert!(stats.cohorts.is_empty());
|
||||
assert!(stats.iteration_jumps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flywheel_stats_normal_snapshot() {
|
||||
let tmp = std::env::temp_dir().join("blueprint_test_normal_snapshot");
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
write_usage(
|
||||
&tmp,
|
||||
serde_json::json!({
|
||||
"project_name": "TestProject",
|
||||
"snapshots": [{
|
||||
"at": "2026-01-01T00:00:00Z",
|
||||
"conventions_version": "1.2.0",
|
||||
"iteration": 1,
|
||||
"tasks": { "total": 10, "done": 6, "dispatched": 8, "blocked": 1 },
|
||||
"blocked_reasons": ["依赖未就绪"],
|
||||
"modules": { "stalled": [] }
|
||||
}]
|
||||
}),
|
||||
);
|
||||
|
||||
let stats = get_flywheel_stats(vec![tmp.to_string_lossy().to_string()]).unwrap();
|
||||
assert_eq!(stats.total_projects_scanned, 1);
|
||||
assert_eq!(stats.projects_with_data, 1);
|
||||
assert_eq!(stats.cohorts.len(), 1);
|
||||
assert_eq!(stats.cohorts[0].conventions_version, "1.2.0");
|
||||
assert_eq!(stats.cohorts[0].project_count, 1);
|
||||
// completion_rate = 6/10 = 0.6,四舍五入到 3 位小数
|
||||
assert!((stats.cohorts[0].completion_rate - 0.6).abs() < 1e-6);
|
||||
assert_eq!(stats.top_blocked_reasons.len(), 1);
|
||||
assert_eq!(stats.top_blocked_reasons[0].reason, "依赖未就绪");
|
||||
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flywheel_stats_iteration_jump() {
|
||||
let tmp = std::env::temp_dir().join("blueprint_test_iteration_jump");
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
write_usage(
|
||||
&tmp,
|
||||
serde_json::json!({
|
||||
"project_name": "JumpProject",
|
||||
"snapshots": [
|
||||
{
|
||||
"at": "2026-01-01T00:00:00Z",
|
||||
"conventions_version": "1.0.0",
|
||||
"iteration": 1,
|
||||
"tasks": { "total": 10, "done": 8, "dispatched": 9, "blocked": 0 },
|
||||
"blocked_reasons": [],
|
||||
"modules": { "stalled": [] }
|
||||
},
|
||||
{
|
||||
"at": "2026-02-01T00:00:00Z",
|
||||
"conventions_version": "1.0.0",
|
||||
"iteration": 2,
|
||||
"tasks": { "total": 15, "done": 3, "dispatched": 10, "blocked": 0 },
|
||||
"blocked_reasons": [],
|
||||
"modules": { "stalled": [] }
|
||||
}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
let stats = get_flywheel_stats(vec![tmp.to_string_lossy().to_string()]).unwrap();
|
||||
assert_eq!(stats.iteration_jumps.len(), 1);
|
||||
let jump = &stats.iteration_jumps[0];
|
||||
assert_eq!(jump.project, "JumpProject");
|
||||
assert_eq!(jump.from_iteration, 1);
|
||||
assert_eq!(jump.to_iteration, 2);
|
||||
// rate_before = 8/10 = 0.8, rate_after = 3/15 = 0.2
|
||||
assert!((jump.rate_before - 0.8).abs() < 1e-6);
|
||||
assert!((jump.rate_after - 0.2).abs() < 1e-6);
|
||||
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,12 @@ pub fn apply_blueprint_buff(
|
||||
crate::commands::blueprint::sync_blueprint_rules(path.clone())
|
||||
.map_err(|e| format!("注入蓝图规则失败: {e}"))?;
|
||||
|
||||
// 独立项目自动创建产品组(含 MCP 注入)
|
||||
if let Err(e) = crate::commands::groups::ensure_standalone_group(&project_id) {
|
||||
// 非致命,不阻断 Buff 应用
|
||||
crate::db::log_event("buff", "warn", &format!("独立产品组创建失败: {e}"), None);
|
||||
}
|
||||
|
||||
// 写入 DB
|
||||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
|
||||
@ -109,6 +109,58 @@ pub fn get_group_maps() -> Result<Vec<ProjectGroupMap>, String> {
|
||||
result
|
||||
}
|
||||
|
||||
/// 确保独立项目有默认产品组。
|
||||
/// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。
|
||||
pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
// 已在某个组内,跳过
|
||||
let already_in_group: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
if already_in_group {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 查项目显示名和所属空间
|
||||
let (project_name, space_id): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT pp.name, pw.space_id FROM project_workspaces pw
|
||||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||||
WHERE pw.id = ?1",
|
||||
params![project_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(|_| format!("项目 {project_id} 不存在或未关联 profile"))?;
|
||||
|
||||
let group_id = uuid::Uuid::new_v4().to_string();
|
||||
let group_name = format!("{project_name} (独立项目)");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO product_groups (id, name, space_id) VALUES (?1, ?2, ?3)",
|
||||
params![group_id, group_name, space_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO project_group_map (project_id, group_id) VALUES (?1, ?2)",
|
||||
params![project_id, group_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id, &group_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("独立项目 MCP 注入失败: {e}"), Some(&ctx));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_group_member(project_id: String, group_id: String, role: Option<String>) -> Result<Option<String>, String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
@ -47,6 +47,14 @@ pub struct ActiveTaskCard {
|
||||
pub blocked_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReviewCoverage {
|
||||
/// 已完成的 M/L 复杂度任务数
|
||||
pub completed_ml_tasks: u32,
|
||||
/// 该项目已有的复盘笔记数
|
||||
pub note_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MentorContext {
|
||||
pub project_id: String,
|
||||
@ -55,6 +63,8 @@ pub struct MentorContext {
|
||||
pub blueprint_summary: Option<BlueprintSummary>,
|
||||
pub active_tasks: Vec<ActiveTaskCard>,
|
||||
pub recent_errors: Vec<RuntimeLogEntry>,
|
||||
/// 复盘笔记覆盖情况
|
||||
pub review_coverage: Option<ReviewCoverage>,
|
||||
/// 格式化好的 Markdown,可直接复制给 Claude
|
||||
pub mentor_markdown: String,
|
||||
}
|
||||
@ -169,6 +179,30 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
|
||||
// 5b. AI 笔记(最近 5 条,作为跨会话记忆)
|
||||
let ai_notes = read_ai_notes(&conn, &project_id);
|
||||
|
||||
// 5c. 复盘笔记覆盖率
|
||||
let review_coverage = {
|
||||
let completed_ml_tasks = match (is_wsl, &wsl_linux_path, &wsl_distro) {
|
||||
(true, Some(linux_path), Some(distro)) => {
|
||||
let unc_root = linux_to_unc(linux_path, distro);
|
||||
count_done_ml_tasks(&unc_root)
|
||||
}
|
||||
(true, _, _) => 0,
|
||||
(false, _, _) => {
|
||||
win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new)
|
||||
.map(count_done_ml_tasks)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
};
|
||||
let note_count = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_notes WHERE project_id = ?1",
|
||||
params![&project_id],
|
||||
|r| r.get::<_, u32>(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Some(ReviewCoverage { completed_ml_tasks, note_count })
|
||||
};
|
||||
|
||||
// 6. 生成 mentor_markdown
|
||||
let mentor_markdown = build_mentor_markdown(
|
||||
&project_id,
|
||||
@ -178,6 +212,7 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
|
||||
&active_tasks,
|
||||
&recent_errors,
|
||||
&ai_notes,
|
||||
review_coverage.as_ref(),
|
||||
);
|
||||
|
||||
Ok(MentorContext {
|
||||
@ -187,6 +222,7 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
|
||||
blueprint_summary,
|
||||
active_tasks,
|
||||
recent_errors,
|
||||
review_coverage,
|
||||
mentor_markdown,
|
||||
})
|
||||
}
|
||||
@ -406,6 +442,23 @@ fn parse_blueprint_summary(root: &Path) -> Option<BlueprintSummary> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计蓝图中已完成的 M/L 复杂度任务数
|
||||
fn count_done_ml_tasks(root: &Path) -> u32 {
|
||||
match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) {
|
||||
Ok(Some(data)) => data
|
||||
.manifest
|
||||
.modules
|
||||
.iter()
|
||||
.flat_map(|m| m.tasks.iter())
|
||||
.filter(|t| {
|
||||
t.status == "done"
|
||||
&& matches!(t.complexity.as_deref(), Some("M") | Some("L"))
|
||||
})
|
||||
.count() as u32,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从项目根路径读取蓝图,返回所有进行中/阻塞的任务卡
|
||||
fn read_active_task_cards(root: &Path) -> Vec<ActiveTaskCard> {
|
||||
match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) {
|
||||
@ -491,6 +544,7 @@ fn build_mentor_markdown(
|
||||
active_tasks: &[ActiveTaskCard],
|
||||
errors: &[RuntimeLogEntry],
|
||||
ai_notes: &[ProjectNote],
|
||||
review_coverage: Option<&ReviewCoverage>,
|
||||
) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
@ -574,6 +628,24 @@ fn build_mentor_markdown(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 复盘笔记覆盖率 ────────────────────────────────────────────────────────
|
||||
if let Some(cov) = review_coverage {
|
||||
md.push_str("\n## 复盘笔记覆盖率\n\n");
|
||||
let gap = cov.completed_ml_tasks.saturating_sub(cov.note_count);
|
||||
md.push_str(&format!(
|
||||
"| 已完成 M/L 任务 | 复盘笔记数 | 缺口 |\n|------|------|------|\n| {} | {} | {} |\n",
|
||||
cov.completed_ml_tasks, cov.note_count, gap
|
||||
));
|
||||
if gap > 0 {
|
||||
md.push_str(&format!(
|
||||
"\n> ⚠️ 有 {} 个 M/L 任务尚无复盘笔记。完成本次任务后请通过 `append_project_note` 补写,格式见 CONVENTIONS.md §交互质量复盘笔记。\n",
|
||||
gap
|
||||
));
|
||||
} else if cov.completed_ml_tasks > 0 {
|
||||
md.push_str("\n> ✅ 所有已完成 M/L 任务均有复盘笔记。\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI 笔记(跨会话记忆)────────────────────────────────────────────────────
|
||||
if !ai_notes.is_empty() {
|
||||
md.push_str("\n## AI 笔记(历史观察)\n\n");
|
||||
|
||||
@ -29,6 +29,9 @@ pub struct ProjectTemplate {
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub files: Vec<TemplateFile>,
|
||||
/// JSON 数组,格式:[{label, description, initCommands}]
|
||||
/// 存在时前端显示下拉让用户选择初始化方式
|
||||
pub variants: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -64,7 +67,7 @@ pub fn list_templates() -> Result<Vec<ProjectTemplate>, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at
|
||||
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at, variants
|
||||
FROM project_templates ORDER BY is_builtin DESC, name",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@ -81,6 +84,7 @@ pub fn list_templates() -> Result<Vec<ProjectTemplate>, String> {
|
||||
is_builtin: row.get::<_, i64>(6)? == 1,
|
||||
created_at: row.get(7)?,
|
||||
files: vec![],
|
||||
variants: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
@ -96,7 +100,7 @@ pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
|
||||
|
||||
let mut tmpl = conn
|
||||
.query_row(
|
||||
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at
|
||||
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at, variants
|
||||
FROM project_templates WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
@ -110,6 +114,7 @@ pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
|
||||
is_builtin: row.get::<_, i64>(6)? == 1,
|
||||
created_at: row.get(7)?,
|
||||
files: vec![],
|
||||
variants: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -194,10 +199,15 @@ pub fn create_project_from_template(
|
||||
project_name: String,
|
||||
platform: String,
|
||||
space_id: Option<String>,
|
||||
variant_init_commands: Option<String>,
|
||||
) -> Result<CreateProjectResult, String> {
|
||||
use tauri::Emitter;
|
||||
|
||||
let tmpl = get_template(template_id)?;
|
||||
let mut tmpl = get_template(template_id)?;
|
||||
// 如果用户选择了 variant,用其 initCommands 覆盖模板默认值
|
||||
if let Some(v) = variant_init_commands {
|
||||
tmpl.init_commands = Some(v);
|
||||
}
|
||||
let mut logs: Vec<String> = Vec::new();
|
||||
|
||||
// 辅助:同时 emit 事件 + 记录到 logs
|
||||
@ -386,6 +396,8 @@ pub fn seed_builtin_templates() {
|
||||
tech_stack: &'static str,
|
||||
init_commands: &'static str,
|
||||
files: Vec<(&'static str, &'static str)>,
|
||||
/// JSON 字符串,存在时前端显示下拉选择初始化方式
|
||||
variants: Option<&'static str>,
|
||||
}
|
||||
|
||||
let templates = vec![
|
||||
@ -396,6 +408,7 @@ pub fn seed_builtin_templates() {
|
||||
platform: "both",
|
||||
tech_stack: "Node,pnpm",
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
@ -428,6 +441,7 @@ pub fn seed_builtin_templates() {
|
||||
platform: "both",
|
||||
tech_stack: "Node,pnpm",
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
@ -451,6 +465,7 @@ pub fn seed_builtin_templates() {
|
||||
platform: "both",
|
||||
tech_stack: "Rust",
|
||||
init_commands: "cargo build",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("Cargo.toml", r#"[package]
|
||||
name = "{{project_name_kebab}}"
|
||||
@ -470,6 +485,7 @@ edition = "2021"
|
||||
platform: "both",
|
||||
tech_stack: "",
|
||||
init_commands: "",
|
||||
variants: None,
|
||||
files: vec![],
|
||||
},
|
||||
Tpl {
|
||||
@ -479,6 +495,7 @@ edition = "2021"
|
||||
platform: "both",
|
||||
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri",
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
@ -658,13 +675,28 @@ pub fn run() {
|
||||
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
|
||||
],
|
||||
},
|
||||
Tpl {
|
||||
id: "builtin-expo",
|
||||
name: "Expo",
|
||||
description: "React Native 移动端 App(Android / iOS)。TypeScript + Expo SDK,适合游戏、工具、内容类手机应用。选择下方模板变体来确定初始化方式。",
|
||||
platform: "both",
|
||||
tech_stack: "React Native,TypeScript,Expo",
|
||||
init_commands: "pnpm create expo-app@latest . --template blank-typescript",
|
||||
variants: Some(r#"[
|
||||
{"label":"blank-typescript(推荐)","description":"最小化 TypeScript 起步,适合游戏类、工具类、独立 App","initCommands":"pnpm create expo-app@latest . --template blank-typescript"},
|
||||
{"label":"tabs(底部标签导航)","description":"内置底部 Tab 栏 + Expo Router,适合多页面内容型 App","initCommands":"pnpm create expo-app@latest . --template tabs"},
|
||||
{"label":"bare(裸原生配置)","description":"完整原生 Expo 配置,可深度定制原生模块,适合进阶项目","initCommands":"pnpm create expo-app@latest . --template bare-minimum"},
|
||||
{"label":"默认(向导模式)","description":"运行官方交互式向导,在终端中选择模板类型","initCommands":"pnpm create expo-app@latest ."}
|
||||
]"#),
|
||||
files: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
for t in &templates {
|
||||
let _ = conn.execute(
|
||||
"INSERT OR IGNORE INTO project_templates
|
||||
(id, name, description, platform, tech_stack, init_commands, is_builtin)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
|
||||
(id, name, description, platform, tech_stack, init_commands, is_builtin, variants)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)",
|
||||
params![
|
||||
t.id,
|
||||
t.name,
|
||||
@ -672,8 +704,14 @@ pub fn run() {
|
||||
t.platform,
|
||||
if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) },
|
||||
if t.init_commands.is_empty() { None } else { Some(t.init_commands) },
|
||||
t.variants,
|
||||
],
|
||||
);
|
||||
// 已存在的模板也补充 variants(INSERT OR IGNORE 不更新已有行)
|
||||
let _ = conn.execute(
|
||||
"UPDATE project_templates SET variants = ?1 WHERE id = ?2 AND is_builtin = 1",
|
||||
params![t.variants, t.id],
|
||||
);
|
||||
|
||||
for (path, content) in &t.files {
|
||||
let file_id = format!("{}-{}", t.id, path.replace('/', "-"));
|
||||
|
||||
@ -27,6 +27,11 @@ pub fn pool() -> &'static DbPool {
|
||||
POOL.get().expect("db not initialized")
|
||||
}
|
||||
|
||||
/// pool 未初始化时返回 None,测试环境中安全调用。
|
||||
pub(crate) fn try_pool() -> Option<&'static DbPool> {
|
||||
POOL.get()
|
||||
}
|
||||
|
||||
fn migrate(conn: &rusqlite::Connection) {
|
||||
conn.execute_batch(
|
||||
"
|
||||
@ -527,6 +532,11 @@ fn migrate(conn: &rusqlite::Connection) {
|
||||
);
|
||||
").expect("create global_tools table");
|
||||
|
||||
// 增量迁移:模板 variants 列(多初始化方式下拉选项)
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE project_templates ADD COLUMN variants TEXT;"
|
||||
);
|
||||
|
||||
// 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析)
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS project_snapshots (
|
||||
|
||||
@ -88,8 +88,8 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
|
||||
|
||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||||
servers[&key] = json!({
|
||||
"type": "http",
|
||||
"url": format!("http://localhost:{}/mcp/group/{}", port, group_id)
|
||||
"type": "sse",
|
||||
"url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
|
||||
});
|
||||
|
||||
write_json(&path, &settings).inspect(|_| {
|
||||
|
||||
@ -1,16 +1,24 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse, Response,
|
||||
},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::{collections::HashMap, convert::Infallible, net::SocketAddr, sync::Arc};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_stream::{wrappers::ReceiverStream, StreamExt as _};
|
||||
|
||||
use crate::{db, db::log_event};
|
||||
|
||||
/// SSE 会话表:session_id → 发送端((event_name, data))
|
||||
type Sessions = Arc<Mutex<HashMap<String, mpsc::Sender<(String, String)>>>>;
|
||||
|
||||
/// 从 settings 表读取 MCP 端口,默认 27190
|
||||
pub fn read_port() -> u16 {
|
||||
db::pool()
|
||||
@ -28,16 +36,22 @@ pub fn read_port() -> u16 {
|
||||
.unwrap_or(27190)
|
||||
}
|
||||
|
||||
/// 启动 MCP HTTP 服务(Streamable HTTP transport)
|
||||
/// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输)
|
||||
pub async fn start(port: u16) {
|
||||
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/mcp/group/:group_id", post(handle_mcp));
|
||||
// Streamable HTTP(POST)— 保持向后兼容,Python 直调仍可用
|
||||
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
|
||||
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回
|
||||
.route("/mcp/group/:group_id/messages", post(handle_sse_message))
|
||||
.with_state(sessions);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(listener) => {
|
||||
log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}"), None);
|
||||
log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}(HTTP + SSE)"), None);
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None);
|
||||
}
|
||||
@ -62,36 +76,108 @@ struct RpcRequest {
|
||||
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();
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct SessionQuery {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
let id = req.id.clone();
|
||||
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
|
||||
|
||||
let result = match req.method.as_str() {
|
||||
"initialize" => json!({
|
||||
async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String> {
|
||||
match req.method.as_str() {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": {
|
||||
"name": "炼境",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}),
|
||||
"tools/list" => tools_list_result(),
|
||||
"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();
|
||||
})),
|
||||
"tools/list" => Ok(tools_list_result()),
|
||||
"tools/call" => Ok(tools_call(group_id, req.params.as_ref()).await),
|
||||
_ => Err(format!("Method not found: {}", req.method)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streamable HTTP(POST)— 向后兼容,Python 直调走此路径 ─────────────────────
|
||||
|
||||
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();
|
||||
match process_rpc(&group_id, &req).await {
|
||||
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
|
||||
Err(msg) => Json(json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": -32601, "message": msg }
|
||||
}))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE 传输(GET + POST /messages)─────────────────────────────────────────
|
||||
|
||||
/// GET /mcp/group/:group_id
|
||||
/// Claude Code 发起 SSE 握手,建立长连接,首条事件告知后续 POST 的 URL。
|
||||
async fn handle_sse_connect(
|
||||
State(sessions): State<Sessions>,
|
||||
Path(group_id): Path<String>,
|
||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = mpsc::channel::<(String, String)>(32);
|
||||
|
||||
// 第一条事件:告知客户端后续 POST 的 endpoint URL
|
||||
let port = read_port();
|
||||
let endpoint_uri = format!(
|
||||
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
|
||||
port, group_id, session_id
|
||||
);
|
||||
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
|
||||
|
||||
sessions.lock().await.insert(session_id, tx);
|
||||
|
||||
let stream = ReceiverStream::new(rx)
|
||||
.map(|(evt_name, data)| Ok::<Event, Infallible>(Event::default().event(evt_name).data(data)));
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// POST /mcp/group/:group_id/messages?session_id=xxx
|
||||
/// 客户端通过此 URL 发送 JSON-RPC 请求,响应经 SSE channel 推回。
|
||||
async fn handle_sse_message(
|
||||
State(sessions): State<Sessions>,
|
||||
Path(group_id): Path<String>,
|
||||
Query(query): Query<SessionQuery>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
let id = req.id.clone();
|
||||
|
||||
let response_json = match process_rpc(&group_id, &req).await {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err(msg) => json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": -32601, "message": msg }
|
||||
}),
|
||||
};
|
||||
|
||||
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response()
|
||||
let mut sessions_guard = sessions.lock().await;
|
||||
if let Some(tx) = sessions_guard.get(&query.session_id) {
|
||||
if tx
|
||||
.send(("message".to_string(), response_json.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// 连接已关闭,惰性清理
|
||||
sessions_guard.remove(&query.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
|
||||
// ── 工具定义 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
listTemplates, createProjectFromTemplate,
|
||||
listTemplates, createProjectFromTemplate, TemplateVariant,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
@ -36,6 +36,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
|
||||
// ── Step 2 ────────────────────────────────────────────────────────────────
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
const [selectedVariantIdx, setSelectedVariantIdx] = useState<number>(0);
|
||||
|
||||
// ── Step 3/4 ──────────────────────────────────────────────────────────────
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
@ -78,6 +79,12 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
(t) => t.platform === "both" || t.platform === platform
|
||||
);
|
||||
|
||||
const selectedTemplate = filteredTemplates.find((t) => t.id === selectedTemplateId) ?? null;
|
||||
const selectedVariants: TemplateVariant[] = (() => {
|
||||
try { return selectedTemplate?.variants ? JSON.parse(selectedTemplate.variants) : []; }
|
||||
catch { return []; }
|
||||
})();
|
||||
|
||||
const projectFullPath = parentPath && projectName
|
||||
? `${parentPath.replace(/[/\\]+$/, "")}\\${projectName}`
|
||||
: "";
|
||||
@ -117,12 +124,16 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
});
|
||||
|
||||
try {
|
||||
const variantCmd = selectedVariants.length > 0
|
||||
? selectedVariants[selectedVariantIdx]?.initCommands
|
||||
: undefined;
|
||||
await createProjectFromTemplate(
|
||||
selectedTemplateId,
|
||||
parentPath,
|
||||
projectName.trim(),
|
||||
platform,
|
||||
spaceId,
|
||||
variantCmd,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
setStep("done");
|
||||
@ -243,12 +254,18 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
{filteredTemplates.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">没有适用于当前平台的模板</p>
|
||||
)}
|
||||
{filteredTemplates.map((t) => (
|
||||
{filteredTemplates.map((t) => {
|
||||
const isSelected = selectedTemplateId === t.id;
|
||||
const variants: TemplateVariant[] = (() => {
|
||||
try { return t.variants ? JSON.parse(t.variants) : []; }
|
||||
catch { return []; }
|
||||
})();
|
||||
return (
|
||||
<div key={t.id} className="space-y-2">
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setSelectedTemplateId(t.id)}
|
||||
onClick={() => { setSelectedTemplateId(t.id); setSelectedVariantIdx(0); }}
|
||||
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${
|
||||
selectedTemplateId === t.id
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-blue-200 hover:bg-gray-50"
|
||||
}`}
|
||||
@ -278,13 +295,46 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{t.initCommands && (
|
||||
{variants.length === 0 && t.initCommands && (
|
||||
<p className="text-[11px] text-gray-300 mt-1 font-mono">$ {t.initCommands.split("\n")[0]}</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* variant 下拉:仅在选中且有 variants 时展开 */}
|
||||
{isSelected && variants.length > 0 && (
|
||||
<div className="ml-3 border-l-2 border-blue-200 pl-3 space-y-1">
|
||||
<p className="text-[10px] text-gray-400 font-medium mb-1.5">选择初始化模板</p>
|
||||
{variants.map((v, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedVariantIdx(idx); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg border transition-all ${
|
||||
selectedVariantIdx === idx
|
||||
? "border-blue-400 bg-blue-50"
|
||||
: "border-gray-100 hover:border-blue-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-3.5 h-3.5 rounded-full border flex-shrink-0 ${
|
||||
selectedVariantIdx === idx
|
||||
? "border-blue-500 bg-blue-500"
|
||||
: "border-gray-300"
|
||||
}`} />
|
||||
<span className="text-xs font-medium text-gray-700">{v.label}</span>
|
||||
</div>
|
||||
{v.description && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 ml-5">{v.description}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-300 mt-0.5 ml-5 font-mono">$ {v.initCommands}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3/4:执行进度 ── */}
|
||||
{(step === "executing" || step === "done") && (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote } from "../../lib/commands";
|
||||
import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote, type ReviewCoverage } from "../../lib/commands";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@ -151,6 +151,11 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 复盘笔记覆盖率 */}
|
||||
{ctx.review_coverage && (
|
||||
<ReviewCoverageSection coverage={ctx.review_coverage} />
|
||||
)}
|
||||
|
||||
{/* AI 笔记 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">AI 笔记</h3>
|
||||
@ -210,6 +215,39 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
|
||||
|
||||
// ── 辅助组件 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function ReviewCoverageSection({ coverage }: { coverage: ReviewCoverage }) {
|
||||
const gap = coverage.completed_ml_tasks - coverage.note_count;
|
||||
const hasGap = gap > 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">复盘笔记覆盖</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<div className="flex-1 bg-gray-50 rounded-lg px-3 py-2 flex flex-col gap-0.5">
|
||||
<span className="text-[11px] text-gray-400">已完成 M/L 任务</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{coverage.completed_ml_tasks}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-50 rounded-lg px-3 py-2 flex flex-col gap-0.5">
|
||||
<span className="text-[11px] text-gray-400">复盘笔记数</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{coverage.note_count}</span>
|
||||
</div>
|
||||
<div className={`flex-1 rounded-lg px-3 py-2 flex flex-col gap-0.5 ${hasGap ? "bg-amber-50" : "bg-green-50"}`}>
|
||||
<span className="text-[11px] text-gray-400">缺口</span>
|
||||
<span className={`text-sm font-semibold ${hasGap ? "text-amber-600" : "text-green-600"}`}>
|
||||
{hasGap ? `−${gap}` : "✓ 0"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasGap && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700">
|
||||
<span className="shrink-0 mt-0.5">⚠️</span>
|
||||
<span>有 {gap} 个 M/L 任务缺少复盘笔记,完成下次任务后请通过 MCP 补写。</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthRow({
|
||||
ok,
|
||||
label,
|
||||
|
||||
@ -679,6 +679,12 @@ export interface TemplateFile {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TemplateVariant {
|
||||
label: string;
|
||||
description: string;
|
||||
initCommands: string;
|
||||
}
|
||||
|
||||
export interface ProjectTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -689,6 +695,8 @@ export interface ProjectTemplate {
|
||||
isBuiltin: boolean;
|
||||
createdAt?: string;
|
||||
files: TemplateFile[];
|
||||
/** JSON 字符串,解析后为 TemplateVariant[],存在时显示下拉选择 */
|
||||
variants?: string;
|
||||
}
|
||||
|
||||
export interface SaveTemplateInput {
|
||||
@ -724,6 +732,7 @@ export const createProjectFromTemplate = (
|
||||
projectName: string,
|
||||
platform: string,
|
||||
spaceId?: string,
|
||||
variantInitCommands?: string,
|
||||
) =>
|
||||
invoke<CreateProjectResult>("create_project_from_template", {
|
||||
templateId,
|
||||
@ -731,6 +740,7 @@ export const createProjectFromTemplate = (
|
||||
projectName,
|
||||
platform,
|
||||
spaceId,
|
||||
variantInitCommands,
|
||||
});
|
||||
|
||||
export const registerAndLinkRepo = (
|
||||
@ -1063,6 +1073,12 @@ export interface ProjectNoteEntry {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionQuality {
|
||||
complexity_dist: Record<string, number>;
|
||||
total_rework_count: number;
|
||||
avg_rounds_m: number | null;
|
||||
}
|
||||
|
||||
export interface FlywheelStats {
|
||||
cohorts: FlywheelCohort[];
|
||||
top_blocked_reasons: BlockedReasonCount[];
|
||||
@ -1078,6 +1094,8 @@ export interface FlywheelStats {
|
||||
projects_with_data: number;
|
||||
/** 跨项目复盘笔记(最近 50 条) */
|
||||
project_notes: ProjectNoteEntry[];
|
||||
/** 跨项目执行质量指标(阶段二扩展) */
|
||||
execution_quality: ExecutionQuality;
|
||||
}
|
||||
|
||||
export const getFlywheelStats = (projectPaths: string[]) =>
|
||||
@ -1127,12 +1145,18 @@ export interface RuntimeLogEntry {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ReviewCoverage {
|
||||
completed_ml_tasks: number;
|
||||
note_count: number;
|
||||
}
|
||||
|
||||
export interface MentorContext {
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
health: ProjectHealth;
|
||||
blueprint_summary?: BlueprintSummary;
|
||||
recent_errors: RuntimeLogEntry[];
|
||||
review_coverage?: ReviewCoverage;
|
||||
mentor_markdown: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user