feat: add runtime diagnostics, health center, and project mentor

运行时可观测性:
- 新增 runtime_logs SQLite 表,提供全局 log_event 写入函数(保留最近 500 条)
- mcp_server/mcp_inject/groups 关键路径接入结构化日志
- MCP 新增 get_diagnostics 工具,返回注入摘要与近期错误
- add_group_member 注入失败时前端显示黄色 toast 警告

健康中心:
- 新增 run_health_check 命令(6 项并行检查:MCP/Git 路径/项目路径/GitHub Token/编辑器/DB 延迟)
- 新增质量指标聚合(休眠项目/孤立项目/注入异常组/无路径项目)
- 侧边栏新增健康中心页面,支持手动刷新和诊断报告复制
- 设置页 MCP 状态改为真实 HTTP ping 检测

项目导师:
- 新增 get_mentor_context 后端命令,聚合健康状态/蓝图摘要/近期错误
- MCP 新增 get_project_mentor_context 工具(含产品组归属校验)
- 项目卡片底部新增 🎓 导师按钮,面板支持复制结构化 Markdown 报告
- 修复 WSL 项目路径识别:wsl_path 存 UNC 格式时自动提取 distro 和 Linux 路径,改用 wsl 命令检查路径存在性(规避 Path::exists() 对 \wsl.localhost\... 不可靠的问题)

AI 工作流:
- 新增 AI-WORKFLOW.md、.claude/skills/ 技能体系配置
- 更新 CLAUDE.md 补充蓝图驱动开发规范
This commit is contained in:
lanrtop 2026-04-04 19:58:49 +09:00
parent c598c9b05b
commit eb346ae1fd
29 changed files with 2666 additions and 60 deletions

View File

@ -334,6 +334,97 @@ Claude 应关注这些信号来决定是否更新蓝图:
> **注意**CONVENTIONS.md 编译进炼境二进制,更新需要修改炼境源码并发版,不支持运行时修改。这是有意为之的设计——保证规则版本可追溯,防止随意污染。
## 复杂功能分析流程(/architect
当需求复杂度为 **L 级**时,在写任何代码之前执行本流程。
### 步骤
1. 读取 `manifest.yaml`,定位相关模块和依赖关系
2. 读取受影响的核心文件,理解现有实现模式和系统边界
3. 输出设计文档,格式如下:
```
## 设计决策:<功能名>
### 背景
<关联的现有模块>
### 方案选择
<2-3 个方案及取舍标注选定方案>
### 受影响文件
- 新增:<路径><用途>
- 修改:<路径><改动说明>
### 系统边界变更
<新增接口 / 数据表 / 字段定义>
### 风险点
- <风险><规避方式>
### 拆解建议
建议拆为 N 张任务卡依赖顺序卡A → 卡B → 卡C
```
4. **不写任何代码**,设计文档经用户确认后再进入拆解流程
---
## 任务拆解流程(/splitter
在设计文档确认后执行,产出可派发任务卡并写入蓝图文件。
### 步骤
1. 确认目标模块 id读取对应 `modules/<id>.md`
2. 按以下原则拆解:
- **粒度**:每张卡 complexity 必须是 S 或 M不允许 L
- **依赖顺序**:数据层 → 后端命令 → 前端封装 → UI 组件
- **可派发标准**:每张卡必须有 `files` + `acceptance` + `complexity`
3. 将任务卡追加到 `modules/<id>.md`
4. 更新 `manifest.yaml`status → `planned`,更新 `updated` 日期
5. 输出拆解摘要卡名、complexity、执行顺序
### 质量检查
- [ ] 每张卡有 `files`
- [ ] 每张卡有 `acceptance`
- [ ] complexity 全部是 S 或 M
- [ ] 有依赖的卡填了 `depends`
- [ ] 任务卡按执行顺序排列
---
## 技能依赖
本蓝图工作流依赖以下 Claude Code 技能处理 L 级复杂任务:
| 技能 | 触发词 | 用途 |
|------|--------|------|
| architect | `/architect` | 复杂功能架构分析,输出设计文档 |
| splitter | `/splitter` | 将设计文档拆解为可派发任务卡 |
### 检测方式
在 Claude Code 中输入 `/architect`,若 Claude 开始执行架构分析步骤则已就绪。
若提示找不到技能,按以下方式安装。
### 安装方式
技能文件随本项目分发,位于 `.claude/skills/`
```bash
# 复制到全局技能目录(任意项目均可使用)
cp -r .claude/skills/architect ~/.claude/skills/
cp -r .claude/skills/splitter ~/.claude/skills/
# 或仅在当前项目使用(.claude/skills/ 已存在则无需操作)
```
> 若通过炼境同步本蓝图规则,`.claude/skills/` 会随蓝图一并分发,无需手动安装。
---
## 注意事项
- 不要删除已完成的任务卡,它们是项目历史的一部分

View File

@ -1,11 +1,16 @@
version: 1
name: 炼境
description: Git 多空间开发管理器集成项目管理、Git 操作、CI/CD、开发工具链管理
description: >
Git 多空间开发管理器。以蓝图为神经系统,以 Claude 为智能层,
持续感知所管理项目的功能运转、质量状态与成长方向,
成为每个项目的贴身导师。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。
iteration: 1
updated: 2026-04-04
areas:
- id: frontend
name: 前端
@ -177,14 +182,14 @@ modules:
- id: wsl-proxy
name: WSL 代理同步
area: infra
area: backend
status: done
progress: 100
position: [500, 650]
- id: v2rayn-import
name: v2rayN 节点读取
area: infra
area: backend
status: done
progress: 100
position: [500, 800]
@ -224,6 +229,27 @@ modules:
progress: 100
position: [1500, 0]
- id: runtime-diagnostics
name: 运行时可观测性
area: infra
status: done
progress: 100
position: [1750, 350]
- id: health-center
name: 健康中心
area: frontend
status: done
progress: 100
position: [1750, 0]
- id: project-mentor
name: 项目导师
area: frontend
status: in_progress
progress: 75
position: [2000, 0]
edges:
# 前端依赖
- from: workspace-mgmt
@ -316,3 +342,36 @@ edges:
- from: product-group-mcp
to: settings-page
type: related
# 运行时可观测性
- from: mcp-server
to: runtime-diagnostics
type: dependency
- from: mcp-config-inject
to: runtime-diagnostics
type: dependency
- from: sqlite-db
to: runtime-diagnostics
type: dependency
# 健康中心
- from: runtime-diagnostics
to: health-center
type: dependency
- from: sqlite-db
to: health-center
type: dependency
# 项目导师(洞察与引导层,自指闭环)
- from: health-center
to: project-mentor
type: dependency
- from: blueprint-flywheel
to: project-mentor
type: dependency
- from: blueprint-feedback
to: project-mentor
type: dependency
- from: mcp-server
to: project-mentor
type: dependency

View File

@ -0,0 +1,90 @@
# 健康中心
呈现炼境核心功能的运行状态与质量分析。分两层代码层做客观健康检查Claude 层做质量推断与优化建议。
## 设计思路
```
Tauri 命令层(客观检测)
├─ Git 仓库路径有效性
├─ 项目本地路径存在性
├─ GitHub Token 有效性
├─ 编辑器路径可用性
└─ SQLite 读写延迟
↓ 聚合为 HealthReport
前端健康中心页面(展示 + 交互)
├─ 每项检查:绿/黄/红状态指示
├─ 质量指标:休眠项目、孤立项目、注入异常组
└─ "生成诊断报告" → 复制结构化 Markdown
↓ 用户粘贴给 Claude / 未来 in-app API
Claude 分析层(推断 + 建议)
└─ 读数据 → 识别模式 → 给出优化建议
```
MCP 路径同步增强:扩展已有 `get_diagnostics` 工具,覆盖全部检查项,让 Claude Code 用户无需打开页面即可获得完整诊断。
## 任务卡
### ✅ T1 · Backend 健康检查聚合命令
- status: done
- complexity: M
- files: src-tauri/src/commands/settings.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance:
- 新增 `run_health_check` Tauri command无必填参数
- 检查项(全部并行执行,超时 5s
- `git_repos`:遍历所有注册仓库,验证 `win_path` 是否为有效 git 目录(`.git` 存在)
- `project_paths`:遍历所有项目,检查 `win_path` / `wsl_path` 是否存在于文件系统
- `github_token`:若 settings 中有 token`https://api.github.com/user` 验证有效性
- `editor_paths`:读取 settings 中配置的编辑器路径,验证文件是否存在
- `db_latency`:计时一次简单写入+删除,返回 ms 数
- 返回结构:`HealthReport { checked_at, items: Vec<HealthItem { id, name, status, detail }> }`
- `status` 枚举:`ok / warn / error / skip`skip 表示未配置该项,不算失败)
### ✅ T2 · 质量指标聚合
- status: done
- complexity: S
- files: src-tauri/src/commands/settings.rs, src/lib/commands.ts
- depends: T1
- acceptance:
- `run_health_check` 返回值中追加 `quality: QualityMetrics`
- 指标:
- `dormant_projects``last_push_at` 超过 30 天且有 repo_url 的项目列表id + name + days
- `orphaned_projects`:未加入任何产品组且无 repo_url 的项目列表
- `groups_with_inject_warn`runtime_logs 中最近有 error/warn 的产品组列表
- `projects_without_path`win_path 和 wsl_path 均为空的项目数
- 所有指标从已有 SQLite 数据计算,无需新表
### ✅ T3 · 前端健康中心页面
- status: done
- complexity: M
- files: src/components/health/HealthPage.tsx, src/components/layout/Sidebar.tsx, src/lib/commands.ts
- depends: T1, T2
- acceptance:
- 侧边栏新增"健康中心"入口(脉搏图标),路由到 HealthPage
- 页面布局:顶部"上次检查时间 + 刷新",下方两列
- 左列:健康检查项列表,每项显示状态图标 + 名称 + 详情文字
- 右列:质量指标卡片(休眠项目数、孤立项目数、注入异常组数)
- 每项 `error` 状态展开显示具体失败原因
- "生成诊断报告"按钮:将 HealthReport 格式化为 Markdown 复制到剪贴板
- 页面挂载时自动执行一次检查,支持手动刷新
### ✅ T4 · 扩展 MCP get_diagnostics
- status: done
- complexity: S
- files: src-tauri/src/mcp_server.rs
- depends: T1, T2
- acceptance:
- `get_diagnostics` MCP 工具调用 `run_health_check` 聚合数据(复用逻辑,不重复实现)
- 返回 Markdown 追加"系统健康"和"质量指标"两节
- Claude Code 用户对话"帮我看下炼境状态"时可直接获得完整报告
## 决策记录
- 不在应用内直接调用 Anthropic API需要用户填 API Key增加配置成本优先做"复制报告"路径,让用户自行粘贴给 Claude
- `run_health_check` 独立于 `check_mcp_health`,后者只查 MCP前者是全局聚合
- 所有检查并行执行tokio::join!),单项超时 5s不阻塞整体
- 质量指标从已有数据计算,不引入新的数据采集,保持实现简单

View File

@ -0,0 +1,82 @@
# 项目导师
炼境作为所管理项目的贴身导师——持续感知每个项目的功能运转状态、模块质量与成长方向,借助 Claude 将数据转化为有观点的引导。炼境自身亦被纳入其中,形成自指闭环。
## 核心定位
```
功能运转感知(代码做)
→ 各模块是否在正常运转?有无停滞、失效、报错?
功能质量理解Claude 做)
→ 各模块实现质量如何?在整体中分量几何?
成长方向引导(代码 + Claude 协作)
→ 下一步该聚焦什么?风险在哪?优化路径是什么?
```
三层能力作用于炼境管理的每一个项目,也作用于炼境自身——
工具与被工具服务的对象合而为一,闭环成立。
## 自指闭环说明
```
炼境 感知其他项目 → 积累模式与洞察
同样机制作用于炼境自身
Claude 读炼境蓝图 + 运行指标
→ 给出炼境自身的优化建议
炼境变得更强 → 更好地理解项目
包括更好地理解自己 ↺
```
炼境通过管理别的项目**认识自己**,通过认识自己**成为更好的导师**。
## 数据基础(上游依赖)
| 数据源 | 提供什么 | 来自哪个模块 |
|--------|---------|------------|
| blueprint 规划态 | 模块设计意图、任务完成度 | blueprint-flywheel |
| runtime_logs | 执行层历史、注入成功率 | runtime-diagnostics |
| usage.json 飞轮 | 跨项目趋势、飞轮持续率 | blueprint-feedback |
| 健康中心指标 | Git 活跃度、CI 状态、路径有效性 | health-center |
| MCP 接口 | Claude 可查询任意维度 | mcp-server |
## 任务卡
### ✅ T1 · 后端聚合 commandMentorContext
- status: done
- complexity: M
- files: src-tauri/src/commands/mentor.rs, src-tauri/src/lib.rs
- acceptance: `get_mentor_context(project_id)` 返回包含 health、blueprint_summary、recent_errors、mentor_markdown 的 MentorContext 结构体,可通过 Tauri invoke 调用成功
### ✅ T2 · MCP 项目导师查询接口
- status: done
- complexity: S
- files: src-tauri/src/mcp_server.rs
- depends: T1
- acceptance: MCP 新增 `get_project_mentor_context` 工具Claude 调用后返回 mentor_markdown 字符串,内容包含健康摘要与近期错误
### ✅ T3 · 前端导师面板
- status: done
- complexity: M
- files: src/components/mentor/MentorPanel.tsx, src/lib/commands.ts, src/components/dashboard/ProjectCard.tsx
- depends: T1
- acceptance: 项目卡片底部出现"🎓"导师按钮,点击展示健康摘要和近期错误,"生成导师报告"按钮点击后将 mentor_markdown 复制到剪贴板
### 📋 T4 · 炼境自指接入
- status: todo
- complexity: S
- files: src-tauri/src/commands/mentor.rs
- depends: T1
- acceptance: 炼境自身dev-manager-tauri作为项目注册后`get_mentor_context` 能正常返回其蓝图摘要和健康数据,不产生递归调用
## 决策记录
- 项目导师是洞察与引导层health-center 是数据基础层,两者是上下游关系,不合并为一个模块
- Claude 是智能层的唯一执行者,代码只负责数据采集与结构化,不在代码层做质量推断
- 炼境自指是设计意图,不是边缘 case——从一开始就将其纳入系统而非事后兼容
- 任务卡保持 concept 状态直到 health-center 完成,避免在数据基础未就绪时过早实现上层

View File

@ -0,0 +1,82 @@
# 运行时可观测性
为炼境建立运行时诊断层。当 Claude 需要排查功能状态时,可以通过 MCP 工具直接拿到结构化的真实数据,而不是靠读代码推断。
## 设计思路
```
运行时事件(启动/注入/调用/报错)
SQLite runtime_logs 表(持久化)
MCP get_diagnostics 工具Claude 可调用)
Claude 得到结构化诊断数据 → 精准定位问题
```
同时修复两个已知的 UI 盲点:
- 设置页 MCP 状态硬编码为"运行中",不反映真实端口状态
- 产品组成员注入失败时前端无感知toast 始终显示成功
## 任务卡
### ✅ T1 · SQLite runtime_logs 表
- status: done
- complexity: S
- files: src-tauri/src/db.rs, src-tauri/src/lib.rs
- acceptance:
- db.rs 新增 runtime_logs 表:`(id, ts TEXT, category TEXT, level TEXT, message TEXT, context TEXT)`
- 提供全局 `log_event(category, level, message, context_json)` 函数,内部 get pool 写入
- 表保留最近 500 条(写入时超出自动删除最旧的)
- db::init() 中建表IF NOT EXISTS
### ✅ T2 · 替换 eprintln! 为结构化日志
- status: done
- complexity: S
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/commands/groups.rs
- depends: T1
- acceptance:
- mcp_server.rs端口绑定成功/失败 → log_event("mcp_server", "info"/"error", ...)
- mcp_inject.rsinject/remove 成功/失败 → log_event("mcp_inject", "info"/"error", context 含 project_id + group_id)
- commands/groups.rsadd_group_member 注入失败 → log_event("mcp_inject", "warn", ...)
- 保留 eprintln! 作为备份输出log_event 写入失败时不 panic
### ✅ T3 · MCP Server 新增 get_diagnostics 工具
- status: done
- complexity: M
- files: src-tauri/src/mcp_server.rs
- depends: T1, T2
- acceptance:
- tools/list 新增 `get_diagnostics` 工具,无必填参数
- 返回结构:
- `server`:端口号、进程启动时间(从全局 OnceCell 记录)
- `inject_summary`:从 runtime_logs 聚合每个 project_id 最后一次注入的 level + message
- `recent_errors`:最近 20 条 level=error/warn 的日志ts + category + message
- 响应为 Markdown 格式文本Claude 可直接阅读
### ✅ T4 · 注入失败结果返回前端
- status: done
- complexity: S
- files: src-tauri/src/commands/groups.rs, src/components/manage/ManagePage.tsx
- depends: T2
- acceptance:
- add_group_member command 返回 `{ ok: bool, inject_warning: Option<String> }`
- 前端 AddMemberModal注入成功 → 绿色 toast "成员已添加MCP 已注入"inject_warning 有值 → 黄色 toast 显示警告内容
- 产品组成员卡片inject 最后一次为 error/warn → 橙色 `⚠ MCP` 徽标替代绿点hover 显示错误信息
### ✅ T5 · 设置页 MCP 真实健康检查
- status: done
- complexity: S
- files: src-tauri/src/commands/settings.rs, src/lib/commands.ts, src/components/settings/SettingsPage.tsx
- depends: T1
- acceptance:
- 新增 `check_mcp_health` Tauri command尝试 HTTP GET `http://localhost:{port}/health`,返回 `{ ok: bool, latency_ms: u64, error: Option<String> }`
- 设置页 MCP 区块:挂载时自动调用一次,显示真实状态(绿色"运行中 {latency}ms" / 红色"端口未响应: {error}"
- 端口保存后自动重新检查
## 决策记录
- runtime_logs 存 SQLite 而非内存,炼境重启后仍可查历史注入记录
- get_diagnostics 返回 Markdown 而非 JSONClaude 读取更自然,无需额外解析
- T4 只改 add_group_memberremove 失败影响较小(幂等清理),不做 UI 反馈
- 表上限 500 条:足够覆盖数天的操作记录,不会无限膨胀

View File

@ -21,6 +21,29 @@
"todo": 7,
"total": 95
}
},
{
"at": "2026-04-04T01:50:46Z",
"blocked_reasons": [],
"conventions_version": "1.3.0",
"iteration": 1,
"modules": {
"blocked": 0,
"concept": 0,
"done": 29,
"in_progress": 0,
"planned": 0,
"stalled": [],
"total": 29
},
"tasks": {
"blocked": 0,
"dispatched": 0,
"done": 106,
"in_progress": 0,
"todo": 0,
"total": 110
}
}
]
}

15
.claude/settings.json Normal file
View File

@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=(i.tool_input||{}).command||'';if(cmd.includes('git commit')){process.stderr.write('\\n📋 提交前检查清单:\\n complexity S → /code-review\\n complexity M → /Dev-edge-case → /code-review\\n\\n');}}catch(e){}});\""
}
]
}
]
}
}

View File

@ -0,0 +1,62 @@
---
name: architect
description: 针对复杂新功能做架构分析,输出设计决策文档,为 splitter 提供输入。Use for L-complexity tasks before writing any code.
allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *)
---
# 架构分析
在写任何代码之前,先理解现有结构,再做设计决策。
## 步骤
### 1. 读取项目上下文
- 读取 `CLAUDE.md` 了解技术栈和目录结构
- 若存在 `.blueprint/manifest.yaml`,读取它了解模块全景和依赖关系
### 2. 分析现有实现模式
根据需求涉及的层次读取核心文件:
- 数据层入口(数据库 schema、model 定义)
- 后端服务层API / command 注册列表)
- 前端调用层API 封装、状态管理)
- 相关功能模块的现有实现(同类功能参考)
### 3. 识别系统边界
确认本次需求涉及的层次变更:
- 是否需要新增数据表或字段
- 是否需要新增后端接口 / command
- 前后端数据流如何变化
### 4. 输出设计文档
```
## 设计决策:<功能名>
### 背景
<关联的现有模块>
### 方案选择
<2-3 个方案及取舍标注选定方案>
### 受影响文件
- 新增:<路径><用途>
- 修改:<路径><改动说明>
### 系统边界变更
<新增接口 / 数据表 / 字段定义>
### 风险点
- <风险><规避方式>
### 拆解建议
建议拆为 N 张任务卡依赖顺序卡A → 卡B → 卡C
```
### 5. 更新蓝图(若项目有 .blueprint/
- 若需要新模块,在 `manifest.yaml` 新增status: concept
- 创建对应 `modules/<id>.md` 骨架
## 原则
- **不写任何代码**,只输出设计文档
- 设计文档要足够具体,让 splitter 能直接产出可派发任务卡
- 需求不清晰时先向用户确认,再开始分析

View File

@ -0,0 +1,65 @@
---
name: catchup
description: 新会话快速续接读取蓝图状态找出中断任务告诉你现在在哪、该干嘛。Use at the start of a new session to resume where you left off.
allowed-tools: Read, Glob, Grep
---
# 会话续接Catch Up
新会话开始时快速还原上下文,找到中断点,给出明确的下一步。
## 步骤
### 1. 读取项目全景
读取 `.blueprint/manifest.yaml`,统计:
- 总模块数 / done / in_progress / planned / concept
- 总任务卡数 / done / in_progress🔵/ todo📋/ blocked🔴
### 2. 找出中断任务(🔵)
遍历所有 `modules/*.md`,找到前缀为 🔵 的任务卡status: in_progress
对每张 🔵 卡:
1. 读取 `files` 字段列出的文件,查看当前代码实际状态
2. 对比任务卡的 `acceptance` 验收标准
3. 判断:已完成多少、还差什么
### 3. 找出可派发任务(📋)
列出所有前缀为 📋 的任务卡status: todo有 files + acceptance按模块分组。
### 4. 找出阻塞任务(🔴)
列出所有前缀为 🔴 的任务卡,摘录 `blocked_reason`
### 5. 输出续接报告
用以下格式输出,**简洁优先**
```
## 当前状态
X/Y 模块完成Z 个进行中
## 中断任务(需要续接)
### 🔵 <任务卡标题><模块名>
- 已完成:<根据代码判断已实现的部分>
- 还差:<未完成的部分>
- 建议:从 <具体位置/函数> 继续
## 可立即派发(📋)
- <模块名> / <任务卡标题>complexity: S/M
...
## 阻塞中(🔴)
- <任务卡标题><blocked_reason 摘要>
```
若没有中断任务,直接列出可派发任务和建议的下一步。
## 原则
- **不写任何代码**,只做状态诊断
- 中断任务的判断依据是代码实际状态,不是任务卡写的
- 若 🔵 任务的代码已经全部实现,直接说"可以关锁(标 ✅)"
- 输出要短,能让用户 30 秒内决定下一步

View File

@ -0,0 +1,82 @@
---
name: splitter
description: 将 architect 的设计文档拆解为任务卡,若项目有 .blueprint/ 则写入蓝图文件。Use after /architect for L-complexity tasks.
allowed-tools: Read, Edit, Write, Glob
---
# 任务拆解
将架构设计拆解为可独立执行的任务卡。
## 前提
当前对话中应已有 `/architect` 输出的设计文档,包含受影响文件和拆解建议。
## 步骤
### 1. 确认任务管理方式
- 若项目存在 `.blueprint/manifest.yaml` → 写入蓝图文件
- 若不存在 → 以 Markdown 列表输出到对话,由用户决定放哪里
### 2. 拆解原则
**粒度控制**
- 每张卡 complexity 必须是 `S``M`,不允许 L
- S单文件改动逻辑简单独立可完成
- M跨 2-3 个文件,有业务逻辑,但边界清晰
- 拆出来还是 L → 继续细拆
**依赖顺序**
- 数据层schema / model→ 后端接口 → 前端封装 → UI 组件
- 有依赖的卡填写 `depends` 字段
**可派发标准(每张卡必须满足)**
- `files`:明确列出涉及文件的相对路径
- `acceptance`:一句话验收标准,描述可观察的结果
- `complexity`S 或 M
### 3a. 写入蓝图(有 .blueprint/ 时)
读取 `.blueprint/modules/<id>.md`,在末尾追加任务卡:
```markdown
### 📋 <任务标题>
- status: todo
- complexity: S|M
- files: src/path/to/file.tsx, src-tauri/src/commands/xxx.rs
- depends: <依赖模块id>(可选)
- acceptance: <一句话验收标准>
```
然后更新 `manifest.yaml`status → `planned``updated` 改为今天日期。
### 3b. 输出到对话(无 .blueprint/ 时)
```markdown
## 任务拆解:<功能名>
### 📋 <卡名>S
- files: ...
- acceptance: ...
### 📋 <卡名>M
- files: ...
- acceptance: ...
- depends: 上一张卡
```
### 4. 输出拆解摘要
```
共 N 张任务卡,执行顺序:
1. 📋 <卡名>S<一句话说明>
2. 📋 <卡名>M<一句话说明>
3. 📋 <卡名>M<一句话说明depends: 卡2>
```
## 质量检查
写完后逐张确认:
- [ ] 每张卡有 `files`
- [ ] 每张卡有 `acceptance`
- [ ] complexity 全部是 S 或 M
- [ ] 有依赖的卡填了 `depends`
- [ ] 任务卡按执行顺序排列

213
AI-WORKFLOW.md Normal file
View File

@ -0,0 +1,213 @@
# AI 辅助开发工作流手册
> 本项目基于蓝图×飞轮工作流,以下技能需要在合适时机手动触发。
---
## 快速参考
| 技能 | 什么时候触发 | 一句话说明 |
|------|------------|-----------|
| `/catchup` | **新会话开始时** | 读蓝图状态,找中断任务,告诉你现在在哪、该干嘛 |
| `/architect` | 需求复杂,不知道从哪里改起 | 先读代码再设计,不要直接开写 |
| `/splitter` | architect 出设计文档之后 | 把设计拆成可执行的任务卡 |
| `/spec-check` | 代码写完,提交之前 | 核对是否满足任务卡的验收标准 |
| `/Dev-edge-case` | M 级任务代码写完之后 | 穷举所有边界情况,找出遗漏 |
| `/code-review` | 每次提交之前 | 审查正确性、安全性、一致性 |
| `/smart-commit` | code-review 通过之后 | 生成规范的 Conventional Commits message |
| `/Dev-deep-think` | 感觉方案不够好但说不清哪里有问题 | 深度迭代,推动思考超越表面 |
| `/simplify` | 重构场景,代码写得太复杂 | 找出可复用和可简化的地方 |
---
## 一次会话的完整节奏
### 开始会话
Claude 自动读取 `.blueprint/manifest.yaml`,你无需操作。
如果是**跨会话续接**(上次没做完、新开了一个对话),运行:
```
/catchup
```
它会告诉你上次做到哪里、中断的任务还差什么、可以立即派发的任务有哪些。
---
### 阶段零聊需求无需触发技能Claude 主动更新蓝图)
聊需求时 Claude 会根据你说的话自动更新蓝图,**你不需要触发任何命令**
| 你说的 | Claude 自动做的 |
|--------|----------------|
| "我想加个 XX 功能" | 在 manifest.yaml 新增模块status: concept建 modules/<id>.md |
| "这个功能需要 A、B、C 三步" | 在模块文件里拆出 3 张任务卡 |
| "这个方案确定了" | 模块 status → planned补充 files 和 acceptance |
| "开始做 XX" | 模块 status → in_progress任务卡前缀改为 🔵 |
| "为什么选这个方案" | 写入模块的「决策记录」 |
> **注意**:如果 Claude 没有主动更新蓝图,可以直接说"帮我更新一下蓝图",它会补上。
---
### 场景一简单任务S 级)
```
1. 告诉 Claude 要做什么
2. Claude 写代码
3. /spec-check ← 核对验收标准(可选,有任务卡时推荐)
4. /code-review ← 审查代码
5. git add
6. /smart-commit ← 规范提交
```
---
### 场景二中等任务M 级)
```
1. 告诉 Claude 要做什么
2. Claude 写代码
3. /spec-check ← 核对验收标准(可选)
4. /Dev-edge-case ← 穷举边界情况
5. 补充遗漏的处理
6. /code-review ← 审查代码
7. git add
8. /smart-commit ← 规范提交
```
---
### 场景三复杂需求L 级,需先拆)
```
1. 告诉 Claude 需求背景
2. /architect ← 先做架构分析,不写代码
3. 确认设计文档
4. /splitter ← 拆成 S/M 任务卡,写入蓝图
5. 逐张任务卡按场景一/二执行
```
---
### 场景四:重构 / 代码质量优化
```
1. /simplify ← 找出过度复杂的地方并简化
2. /code-review ← 确认重构没有引入问题
3. git add
4. /smart-commit
```
---
## 各技能详解
### `/catchup` — 会话续接
**触发信号**
- 新开了一个对话,上次任务没做完
- 不确定上次做到哪里了
- 想快速了解当前项目状态和可做的事
**产出**:中断任务的续接建议 + 可派发任务列表 + 阻塞任务摘要
---
### `/architect` — 架构分析
**触发信号**
- 这个需求涉及多个文件,不知道改哪里
- 需要新增数据表或后端接口
- 感觉随便改会破坏现有逻辑
**不要触发的情况**
- 需求很明确只改一两个文件直接做S/M 级)
**产出**:设计文档(方案选择 / 受影响文件 / 系统边界 / 风险点)
---
### `/splitter` — 任务拆解
**触发信号**
- `/architect` 刚出了设计文档
- 设计文档里有"拆解建议"
**前提**:当前对话中必须有 architect 的设计文档,否则结果不可靠。
**产出**:写入 `.blueprint/modules/<id>.md` 的任务卡(有 files + acceptance
---
### `/spec-check` — 验收核对
**触发信号**
- 代码刚写完,提交之前
- 任务卡有明确的 acceptance 字段
**价值**:避免"代码跑通了但没满足当初说好的验收条件"
---
### `/Dev-edge-case` — 边界穷举
**触发信号**
- 任务涉及条件判断、数据校验、异步操作
- M 级任务代码写完后
- code-review 前
**注意**:它只列出缺失的处理,不评价代码好坏。看完它的输出再决定要不要补充。
---
### `/code-review` — 代码审查
**触发信号**:每次 `git add` 之后,`git commit` 之前。
**审查维度**:正确性 / 安全性 / 一致性 / 可维护性 / 性能
**输出**
- ✅ 好的做法
- ⚠️ 建议改进(非阻塞)
- ❌ 必须修复(阻塞提交)
---
### `/smart-commit` — 规范提交
**触发信号**code-review 没有 ❌ 问题之后。
**格式**Conventional Commits
```
feat: 新功能
fix: 修复
refactor: 重构
docs: 文档
style: 样式
test: 测试
chore: 构建/工具
```
**注意**:它会展示生成的 message等你确认后才执行 `git commit`
---
### `/Dev-deep-think` — 深度思考
**触发信号**
- 方案能跑,但感觉哪里不对劲
- code-review 有 ⚠️ 但不确定怎么改
- 想在提交前再推敲一遍设计
**不要滥用**S 级任务不需要,会浪费上下文。
---
### `/simplify` — 简化重构
**触发信号**
- 某段代码越改越复杂
- 发现有重复逻辑可以合并
- 明确是重构任务,不是新功能
---
## 自动触发的技能(无需手动)
以下技能 Claude 会根据上下文自动调用,不需要你输入:
| 技能 | 自动触发条件 |
|------|------------|
| `claude-api` | 代码中出现 `import anthropic` 等 |
| `update-config` | 你说"每次 XX 时自动做 YY"类需求 |

View File

@ -2,28 +2,7 @@
Git 多空间开发管理器Tauri v2 + React + TypeScript + Rust。
## 项目蓝图
本项目使用 `.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 独立完成
<!-- 手动维护区:只放技术栈、目录结构、技能规则。蓝图工作流由下方 MANAGED 块统一管理,勿在此重复。 -->
## 技术栈
- 前端React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow
@ -43,6 +22,20 @@ src-tauri/src/commands/ Tauri command 实现
docs/ 部署和更新指南
```
## 技能使用规则
根据任务卡 complexity 决定执行后的检查:
| complexity | 执行后必跑 |
|-----------|-----------|
| S简单增删改 | /code-review → /smart-commit |
| M中等逻辑 | /Dev-edge-case → /code-review → /smart-commit |
| L复杂应先拆 | /architect → /splitter → 拆成 S/M |
- 新功能设计阶段:/architect
- 代码重构/简化:/simplify → /code-review
- 发现深层问题:/Dev-deep-think
<!-- BLUEPRINT_MANAGED_START version="1.3.0" -->
## 项目蓝图

View File

@ -1031,13 +1031,39 @@ fn read_manifest_updated(path: &Path) -> Option<String> {
}
fn is_content_stale(updated: &Option<String>) -> bool {
// 超过 30 天未更新视为 content_stale
// 无 updated 字段视为 stale
let Some(date_str) = updated else { return true };
// 简单按字符串比较(格式 YYYY-MM-DD不引入额外依赖
// 当前日期超过 updated 30 天date_str < (now - 30d)
// 这里用保守策略:有 updated 就认为不是 stale让用户自行判断
let _ = date_str;
false
// 格式 YYYY-MM-DD用字符串比较即可lexicographic == chronological
// 计算 30 天前的日期:取当前系统时间减去 30 * 86400 秒
let threshold = {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let past = secs.saturating_sub(30 * 86400);
let days_since_epoch = past / 86400;
// 将 Unix 天数转为 YYYY-MM-DD无需外部依赖
unix_days_to_date(days_since_epoch)
};
// updated < threshold 说明超过 30 天未更新
date_str.as_str() < threshold.as_str()
}
/// 将 Unix 纪元天数转为 "YYYY-MM-DD" 字符串(无外部依赖)
fn unix_days_to_date(days: u64) -> String {
// 参考https://howardhinnant.github.io/date_algorithms.html civil_from_days
let z = days as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}", y, m, d)
}
fn replace_managed_section(content: &str, new_section: &str) -> String {

View File

@ -1,4 +1,4 @@
use crate::db::pool;
use crate::db::{log_event, pool};
use crate::mcp_inject;
use crate::models::{ProductGroup, ProjectGroupMap};
use rusqlite::params;
@ -75,7 +75,8 @@ pub fn delete_group(id: String) -> Result<(), String> {
.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);
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
log_event("mcp_inject", "warn", &format!("删除组时清除 MCP 配置失败: {e}"), Some(&ctx));
}
}
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
@ -109,7 +110,7 @@ pub fn get_group_maps() -> Result<Vec<ProjectGroupMap>, String> {
}
#[tauri::command]
pub fn add_group_member(project_id: String, group_id: String, role: Option<String>) -> Result<(), String> {
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())?;
conn.execute(
"INSERT OR REPLACE INTO project_group_map (project_id, group_id, role) VALUES (?1, ?2, ?3)",
@ -117,10 +118,15 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
)
.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(())
let inject_warning = match mcp_inject::inject_project_mcp(&project_id, &group_id) {
Ok(()) => None,
Err(e) => {
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "error", &format!("注入失败: {e}"), Some(&ctx));
Some(e)
}
};
Ok(inject_warning)
}
#[tauri::command]
@ -128,7 +134,8 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S
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);
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));
}
conn.execute(
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",

View File

@ -0,0 +1,425 @@
use crate::db::pool;
use rusqlite::params;
use serde::Serialize;
use std::path::{Path, PathBuf};
// ── 返回结构 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct ProjectHealth {
pub path_valid: bool,
pub has_git: bool,
/// git log --oneline -1 输出
pub last_commit: Option<String>,
/// 项目记录的技术栈
pub tech_stack: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct BlueprintSummary {
pub module_count: u32,
pub done_count: u32,
pub in_progress: u32,
pub planned: u32,
pub total_tasks: u32,
pub done_tasks: u32,
/// 可派发任务数
pub dispatchable: u32,
}
#[derive(Debug, Serialize)]
pub struct RuntimeLogEntry {
pub ts: String,
pub category: String,
pub level: String,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct MentorContext {
pub project_id: String,
pub project_name: String,
pub health: ProjectHealth,
pub blueprint_summary: Option<BlueprintSummary>,
pub recent_errors: Vec<RuntimeLogEntry>,
/// 格式化好的 Markdown可直接复制给 Claude
pub mentor_markdown: String,
}
// ── Tauri command ─────────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 1. 从 DB 读取项目基本信息
let row: rusqlite::Result<(String, Option<String>, Option<String>, Option<String>, Option<String>)> = conn
.query_row(
"SELECT pp.name, pw.win_path, pw.wsl_path, pw.platform, pp.tech_stack
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pw.id = ?1",
params![&project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
);
let (project_name, win_path, wsl_path, platform, tech_stack) =
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
// 2. 判断是否为 WSL 项目,并提前解析 distro只解析一次健康检查和蓝图共用
// - platform == "wsl":明确标记
// - 旧数据兼容wsl_path 有值且 win_path 为空(迁移时 COALESCE 可能错填 'windows'
let is_wsl = platform.as_deref() == Some("wsl")
|| (wsl_path.is_some()
&& (win_path.is_none() || win_path.as_deref() == Some("")));
// 2b. 若 wsl_path 存的是 UNC 路径(\\wsl.localhost\Distro\...
// 自动提取 distro 和对应的 Linux 路径;否则视为原始 Linux 路径
let (wsl_distro, wsl_linux_path): (Option<String>, Option<String>) = if is_wsl {
match wsl_path.as_deref() {
Some(p) if p.starts_with("\\\\wsl.localhost\\") || p.starts_with("\\\\wsl$\\") => {
// UNC 格式:\\wsl.localhost\Ubuntu-22.04\home\user\project
let (distro, linux) = unc_to_distro_and_linux(p);
(Some(distro), Some(linux))
}
Some(p) if !p.is_empty() => {
// Linux 格式:/home/user/project
let configured = read_wsl_distro(&conn);
let distro = resolve_wsl_distro(&configured, p);
(Some(distro), Some(p.to_string()))
}
_ => (None, None),
}
} else {
(None, None)
};
// 3. 本地健康检查
let (path_valid, has_git, last_commit) = match (is_wsl, &wsl_linux_path, &wsl_distro) {
(true, Some(linux_path), Some(distro)) => {
let pv = wsl_test_dir(distro, linux_path);
let hg = pv && wsl_test_dir(distro, &format!("{}/.git", linux_path));
let lc = if hg { wsl_git_log(distro, linux_path) } else { None };
(pv, hg, lc)
}
(true, _, _) => (false, false, None),
(false, _, _) => match win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new) {
Some(root) => {
let pv = root.exists();
let hg = root.join(".git").exists();
let lc = if hg { win_git_log(root) } else { None };
(pv, hg, lc)
}
None => (false, false, None),
},
};
let health = ProjectHealth {
path_valid,
has_git,
last_commit,
tech_stack,
};
// 4. 蓝图摘要(复用已解析的 wsl_distro / wsl_linux_path
let blueprint_summary = match (is_wsl, &wsl_linux_path, &wsl_distro) {
(true, Some(linux_path), Some(distro)) => {
read_blueprint_summary_wsl(linux_path, distro)
}
(true, _, _) => None,
(false, _, _) => {
win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new).and_then(read_blueprint_summary_win)
}
};
// 5. 近期错误日志
let recent_errors = read_recent_errors(&conn, &project_id);
// 6. 生成 mentor_markdown
let mentor_markdown = build_mentor_markdown(
&project_id,
&project_name,
&health,
&blueprint_summary,
&recent_errors,
);
Ok(MentorContext {
project_id,
project_name,
health,
blueprint_summary,
recent_errors,
mentor_markdown,
})
}
// ── WSL 辅助 ──────────────────────────────────────────────────────────────────
/// 从 settings 表读取 wsl_distro默认 "Ubuntu"
fn read_wsl_distro(conn: &rusqlite::Connection) -> String {
conn.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string())
}
/// 用 `wsl -d <distro> -- test -d <path>` 检查 Linux 路径是否存在(目录)
/// 比 Path::exists() 对 \\wsl.localhost\... UNC 路径更可靠
/// stderr 重定向到 null避免 WSL 网络警告打印到控制台
fn wsl_test_dir(distro: &str, linux_path: &str) -> bool {
std::process::Command::new("wsl")
.args(["-d", distro, "--", "test", "-d", linux_path])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// 解析 WSL 项目实际使用的发行版名称:
/// 1. 先试配置的 distro
/// 2. 失败则枚举 `wsl -l -q` 找第一个能访问该 linux_path 的 distro
fn resolve_wsl_distro(configured: &str, linux_path: &str) -> String {
if wsl_test_dir(configured, linux_path) {
return configured.to_string();
}
// 枚举所有已安装 distro
let distros = list_wsl_distros();
distros.into_iter()
.find(|d| wsl_test_dir(d, linux_path))
.unwrap_or_else(|| configured.to_string())
}
/// 列出系统所有已安装 WSL 发行版(解析 UTF-16 LE 输出)
fn list_wsl_distros() -> Vec<String> {
let output = match std::process::Command::new("wsl")
.args(["-l", "-q"])
.stderr(std::process::Stdio::null())
.output()
{
Ok(o) => o,
Err(_) => return vec![],
};
let bytes = &output.stdout;
let text = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
let words: Vec<u16> = bytes[2..]
.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&words).to_string()
} else if bytes.windows(2).any(|w| w[1] == 0) {
let words: Vec<u16> = bytes
.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&words).to_string()
} else {
String::from_utf8_lossy(bytes).to_string()
};
text.lines()
.map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\0').to_string())
.filter(|l| !l.is_empty())
.collect()
}
/// 在 WSL 内运行 git log返回最近一条提交的单行摘要
fn wsl_git_log(distro: &str, linux_path: &str) -> Option<String> {
let output = std::process::Command::new("wsl")
.args(["-d", distro, "--", "git", "-C", linux_path, "log", "--oneline", "-1"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
} else {
None
}
}
/// 将 Linux 路径转为 \\wsl.localhost\<distro>\... UNC 路径
fn linux_to_unc(linux_path: &str, distro: &str) -> PathBuf {
let inner = linux_path.trim_start_matches('/').replace('/', "\\");
PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner))
}
/// UNC 路径 → (distro, linux_path)
/// \\wsl.localhost\Ubuntu-22.04\home\user\proj → ("Ubuntu-22.04", "/home/user/proj")
fn unc_to_distro_and_linux(unc: &str) -> (String, String) {
let prefix = if unc.starts_with("\\\\wsl.localhost\\") {
"\\\\wsl.localhost\\"
} else {
"\\\\wsl$\\"
};
let rest = &unc[prefix.len()..];
let mut parts = rest.splitn(2, '\\');
let distro = parts.next().unwrap_or("Ubuntu").to_string();
let inner = parts.next().unwrap_or("");
let linux = format!("/{}", inner.replace('\\', "/"));
(distro, linux)
}
// ── Windows 辅助 ──────────────────────────────────────────────────────────────
fn win_git_log(root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
// ── 蓝图摘要 ──────────────────────────────────────────────────────────────────
/// WSL 项目:用 wsl 命令判断 manifest 存在性,再通过 UNC 路径读取内容
fn read_blueprint_summary_wsl(linux_path: &str, distro: &str) -> Option<BlueprintSummary> {
let manifest_linux = format!("{}/.blueprint/manifest.yaml", linux_path);
// 用 wsl test -f 判断文件存在(不依赖 Path::exists()
let exists = std::process::Command::new("wsl")
.args(["-d", distro, "--", "test", "-f", &manifest_linux])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !exists {
return None;
}
// 文件存在,通过 UNC 路径读取CreateFileW 可正常访问 \\wsl.localhost\...
let unc_root = linux_to_unc(linux_path, distro);
parse_blueprint_summary(&unc_root)
}
/// Windows 项目:走常规 Path::exists()
fn read_blueprint_summary_win(root: &Path) -> Option<BlueprintSummary> {
if !root.join(".blueprint").join("manifest.yaml").exists() {
return None;
}
parse_blueprint_summary(root)
}
fn parse_blueprint_summary(root: &Path) -> Option<BlueprintSummary> {
let result = crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string());
match result {
Ok(Some(data)) => {
let s = &data.stats;
Some(BlueprintSummary {
module_count: s.total_modules,
done_count: s.done,
in_progress: s.in_progress,
planned: s.planned,
total_tasks: s.total_tasks,
done_tasks: s.tasks_done,
dispatchable: s.dispatchable,
})
}
_ => {
// manifest.yaml 存在但解析失败时,返回有蓝图但无法读取的状态
Some(BlueprintSummary {
module_count: 0,
done_count: 0,
in_progress: 0,
planned: 0,
total_tasks: 0,
done_tasks: 0,
dispatchable: 0,
})
}
}
}
// ── 错误日志 ──────────────────────────────────────────────────────────────────
fn read_recent_errors(
conn: &rusqlite::Connection,
project_id: &str,
) -> Vec<RuntimeLogEntry> {
let sql = "SELECT ts, category, level, message FROM runtime_logs
WHERE level IN ('error', 'warn')
AND (json_extract(context, '$.project_id') = ?1
OR context IS NULL)
ORDER BY id DESC LIMIT 20";
let result = conn.prepare(sql).and_then(|mut stmt| {
stmt.query_map(params![project_id], |row| {
Ok(RuntimeLogEntry {
ts: row.get(0)?,
category: row.get(1)?,
level: row.get(2)?,
message: row.get(3)?,
})
})
.map(|iter| iter.filter_map(|r| r.ok()).collect::<Vec<_>>())
});
result.unwrap_or_default()
}
// ── Markdown 生成 ─────────────────────────────────────────────────────────────
fn build_mentor_markdown(
project_id: &str,
project_name: &str,
health: &ProjectHealth,
blueprint: &Option<BlueprintSummary>,
errors: &[RuntimeLogEntry],
) -> String {
let mut md = String::new();
md.push_str(&format!("# 项目导师报告:{}\n\n", project_name));
md.push_str(&format!("项目 ID`{}`\n\n", project_id));
// ── 本地健康 ──────────────────────────────────────────────────────────────
md.push_str("## 本地健康\n\n");
let path_icon = if health.path_valid { "" } else { "" };
let git_icon = if health.has_git { "" } else { "⚠️" };
md.push_str(&format!("- 本地路径:{} {}\n", path_icon, if health.path_valid { "有效" } else { "路径不存在" }));
md.push_str(&format!("- Git 仓库:{} {}\n", git_icon, if health.has_git { "已初始化" } else { "未找到 .git 目录" }));
if let Some(ref stack) = health.tech_stack {
md.push_str(&format!("- 技术栈:{}\n", stack));
}
if let Some(ref commit) = health.last_commit {
md.push_str(&format!("- 最近提交:`{}`\n", commit));
}
// ── 蓝图状态 ──────────────────────────────────────────────────────────────
md.push_str("\n## 蓝图状态\n\n");
if let Some(ref bp) = blueprint {
if bp.module_count == 0 {
md.push_str("蓝图存在,但暂无模块数据。\n");
} else {
let progress = (bp.done_count as f64 / bp.module_count as f64 * 100.0).round() as u32;
md.push_str("| 维度 | 数值 |\n|------|------|\n");
md.push_str(&format!("| 模块总数 | {} |\n", bp.module_count));
md.push_str(&format!("| 已完成模块 | {} ({progress}%) |\n", bp.done_count));
md.push_str(&format!("| 进行中 | {} |\n", bp.in_progress));
md.push_str(&format!("| 已规划 | {} |\n", bp.planned));
md.push_str(&format!("| 任务总数 | {} |\n", bp.total_tasks));
md.push_str(&format!("| 已完成任务 | {} |\n", bp.done_tasks));
md.push_str(&format!("| 可派发任务 | {} |\n", bp.dispatchable));
}
} else {
md.push_str("该项目尚未接入蓝图(无 .blueprint/manifest.yaml\n");
}
// ── 近期错误日志 ──────────────────────────────────────────────────────────
md.push_str("\n## 近期错误日志(最多 20 条)\n\n");
if errors.is_empty() {
md.push_str("无错误记录 ✅\n");
} else {
for e in errors {
md.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", e.ts, e.level, e.category, e.message));
}
}
md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。\n");
md
}

View File

@ -18,3 +18,4 @@ pub mod tags;
pub mod cicd;
pub mod blueprint;
pub mod canvas;
pub mod mentor;

View File

@ -1,5 +1,6 @@
use crate::db::pool;
use crate::db::{log_event, pool};
use rusqlite::params;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::HashMap;
@ -83,8 +84,8 @@ pub fn update_settings(settings: HashMap<String, String>, space_id: Option<Strin
// 端口变更后重新注入所有产品组成员的 MCP 配置
if mcp_port_changed {
let errors = crate::mcp_inject::reinject_all_mcp();
if !errors.is_empty() {
eprintln!("[MCP reinject] 部分项目重注入失败: {:?}", errors);
for e in &errors {
log_event("mcp_inject", "error", &format!("端口变更重注入失败: {e}"), None);
}
}
Ok(())
@ -203,6 +204,376 @@ pub fn search_editor_path(cmd: String) -> Result<Option<String>, String> {
Ok(None)
}
/// 检测 MCP Server 是否真正在监听(实际 HTTP ping
#[tauri::command]
pub fn check_mcp_health() -> Result<serde_json::Value, String> {
let port = crate::mcp_server::read_port();
let url = format!("http://localhost:{}/health", port);
let start = std::time::Instant::now();
match ureq::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.get(&url)
.call()
{
Ok(_) => {
let latency_ms = start.elapsed().as_millis() as u64;
Ok(serde_json::json!({ "ok": true, "latency_ms": latency_ms, "port": port }))
}
Err(e) => Ok(serde_json::json!({ "ok": false, "error": e.to_string(), "port": port })),
}
}
// ── 健康检查 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)]
pub struct HealthItem {
pub id: String,
pub name: String,
/// "ok" | "warn" | "error" | "skip"
pub status: String,
pub detail: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct HealthReport {
pub checked_at: String,
pub items: Vec<HealthItem>,
pub quality: QualityMetrics,
}
fn err_item(id: &str, name: &str, detail: &str) -> HealthItem {
HealthItem { id: id.into(), name: name.into(), status: "error".into(), detail: Some(detail.into()) }
}
fn skip_item(id: &str, name: &str, detail: &str) -> HealthItem {
HealthItem { id: id.into(), name: name.into(), status: "skip".into(), detail: Some(detail.into()) }
}
/// 检测 MCP Server 是否响应
fn check_mcp() -> HealthItem {
let port = crate::mcp_server::read_port();
let url = format!("http://localhost:{}/health", port);
let start = std::time::Instant::now();
match ureq::builder().timeout(std::time::Duration::from_secs(5)).build().get(&url).call() {
Ok(_) => HealthItem {
id: "mcp".into(), name: "MCP Server".into(), status: "ok".into(),
detail: Some(format!("端口 {} 响应正常,延迟 {}ms", port, start.elapsed().as_millis())),
},
Err(e) => HealthItem {
id: "mcp".into(), name: "MCP Server".into(), status: "error".into(),
detail: Some(format!("端口 {} 无响应: {}", port, e)),
},
}
}
/// 检测已注册仓库的本地 .git 目录是否存在(仅检测关联了 git_repos 的 workspace
fn check_git_paths() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != '' AND repo_id IS NOT NULL"
).map_err(|e| e.to_string())?;
let paths: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.flatten().collect();
if paths.is_empty() {
return Ok(skip_item("git_paths", "Git 仓库路径", "暂无本地路径"));
}
let total = paths.len();
let invalid: Vec<&str> = paths.iter()
.filter(|p| !std::path::Path::new(p).join(".git").exists())
.map(|p| p.as_str()).collect();
if invalid.is_empty() {
Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: "ok".into(),
detail: Some(format!("全部 {} 个仓库路径有效", total)) })
} else {
let status = if invalid.len() == total { "error" } else { "warn" };
Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: status.into(),
detail: Some(format!("{}/{} 个 .git 目录不存在", invalid.len(), total)) })
}
})();
result.unwrap_or_else(|e| err_item("git_paths", "Git 仓库路径", &e))
}
/// 检测项目本地路径win_path是否存在于文件系统
fn check_project_paths() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != ''"
).map_err(|e| e.to_string())?;
let paths: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.flatten().collect();
if paths.is_empty() {
return Ok(skip_item("project_paths", "项目本地路径", "暂无配置路径的项目"));
}
let total = paths.len();
let missing = paths.iter().filter(|p| !std::path::Path::new(p).exists()).count();
if missing == 0 {
Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "ok".into(),
detail: Some(format!("全部 {} 个路径存在", total)) })
} else {
Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "warn".into(),
detail: Some(format!("{}/{} 个路径不存在(可能已移动或删除)", missing, total)) })
}
})();
result.unwrap_or_else(|e| err_item("project_paths", "项目本地路径", &e))
}
/// 检测 GitHub Token 是否有效settings.github_token
fn check_github_token() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let token: Option<String> = conn.query_row(
"SELECT value FROM settings WHERE key = 'github_token'",
[], |row| row.get(0),
).ok();
let token = match token {
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
_ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")),
};
match ureq::builder().timeout(std::time::Duration::from_secs(5)).build()
.get("https://api.github.com/user")
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "dev-manager-tauri")
.call()
{
Ok(resp) => {
let username = resp.into_json::<Value>().ok()
.and_then(|v| v["login"].as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "未知账户".into());
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(),
detail: Some(format!("Token 有效,账户: {}", username)) })
}
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(),
detail: Some(format!("Token 失效或网络不可达: {}", e)) }),
}
})();
result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e))
}
/// 检测活跃编辑器路径是否可用settings.active_editor JSON
fn check_editor() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let raw: Option<String> = conn.query_row(
"SELECT value FROM settings WHERE key = 'active_editor'",
[], |row| row.get(0),
).ok();
let raw = match raw {
Some(s) if !s.trim().is_empty() => s,
_ => return Ok(skip_item("editor", "编辑器路径", "未配置活跃编辑器")),
};
let v: Value = serde_json::from_str(&raw)
.unwrap_or_else(|_| Value::Null);
let name = v["name"].as_str().unwrap_or("编辑器").to_string();
let path = v["path"].as_str().unwrap_or("").to_string();
if path.is_empty() {
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some(format!("{} 未找到可执行路径", name)) });
}
if std::path::Path::new(&path).exists() {
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "ok".into(),
detail: Some(format!("{} 路径有效", name)) })
} else {
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "error".into(),
detail: Some(format!("{} 可执行文件不存在: {}", name, path)) })
}
})();
result.unwrap_or_else(|e| err_item("editor", "编辑器路径", &e))
}
/// 检测 SQLite 读写延迟
fn check_db_latency() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let start = std::time::Instant::now();
conn.execute_batch("
BEGIN;
INSERT OR REPLACE INTO settings (key, value) VALUES ('__health_probe__', '1');
DELETE FROM settings WHERE key = '__health_probe__';
COMMIT;
").map_err(|e| e.to_string())?;
let ms = start.elapsed().as_millis();
let status = if ms < 100 { "ok" } else { "warn" };
Ok(HealthItem { id: "db_latency".into(), name: "数据库延迟".into(), status: status.into(),
detail: Some(format!("读写延迟 {}ms{}", ms, if ms >= 100 { "(偏高)" } else { "" })) })
})();
result.unwrap_or_else(|e| err_item("db_latency", "数据库延迟", &e))
}
// ── 质量指标 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct DormantProject {
pub id: String,
pub name: String,
pub days_since_push: i64,
}
#[derive(Debug, Serialize)]
pub struct OrphanedProject {
pub id: String,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct QualityMetrics {
/// last_push_at 超过 30 天且有 repo_url 的项目
pub dormant_projects: Vec<DormantProject>,
/// 未加入任何产品组且无 repo_url 的项目
pub orphaned_projects: Vec<OrphanedProject>,
/// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表
pub groups_with_inject_warn: Vec<String>,
/// win_path 和 wsl_path 均为空的项目数
pub projects_without_path: i64,
}
impl Default for QualityMetrics {
fn default() -> Self {
Self {
dormant_projects: vec![],
orphaned_projects: vec![],
groups_with_inject_warn: vec![],
projects_without_path: 0,
}
}
}
fn collect_quality_metrics() -> QualityMetrics {
let conn = match pool().get() {
Ok(c) => c,
Err(_) => return QualityMetrics::default(),
};
// 1. 休眠项目last_push_at 超 30 天 且 有 repo_url
let dormant_projects: Vec<DormantProject> = {
let stmt = conn.prepare(
"SELECT pw.id, pp.name,
CAST(julianday('now') - julianday(pw.last_push_at) AS INTEGER) as days
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE (pp.repo_url IS NOT NULL AND pp.repo_url != '')
AND pw.last_push_at IS NOT NULL AND pw.last_push_at != ''
AND julianday('now') - julianday(pw.last_push_at) > 30
ORDER BY days DESC"
);
match stmt {
Err(_) => vec![],
Ok(mut stmt) => stmt
.query_map([], |row| {
Ok(DormantProject {
id: row.get(0)?,
name: row.get(1)?,
days_since_push: row.get(2)?,
})
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
}
};
// 2. 孤立项目:无 repo_url 且不在任何产品组
let orphaned_projects: Vec<OrphanedProject> = {
let stmt = conn.prepare(
"SELECT pw.id, pp.name
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE (pp.repo_url IS NULL OR pp.repo_url = '')
AND pw.id NOT IN (SELECT project_id FROM project_group_map)"
);
match stmt {
Err(_) => vec![],
Ok(mut stmt) => stmt
.query_map([], |row| {
Ok(OrphanedProject { id: row.get(0)?, name: row.get(1)? })
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
}
};
// 3. 近 7 天有注入 error/warn 的产品组(解析 context JSON 取 group_id
let groups_with_inject_warn: Vec<String> = {
let stmt = conn.prepare(
"SELECT DISTINCT context FROM runtime_logs
WHERE category = 'mcp_inject'
AND level IN ('error', 'warn')
AND ts > datetime('now', '-7 days')
AND context IS NOT NULL"
);
let contexts: Vec<String> = match stmt {
Err(_) => vec![],
Ok(mut stmt) => stmt
.query_map([], |row| row.get::<_, String>(0))
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
};
let mut group_ids: Vec<String> = contexts.iter()
.filter_map(|ctx| {
serde_json::from_str::<Value>(ctx).ok()
.and_then(|v| v["group_id"].as_str().map(|s| s.to_string()))
})
.collect();
group_ids.sort();
group_ids.dedup();
group_ids
};
// 4. 无路径项目数
let projects_without_path: i64 = conn.query_row(
"SELECT COUNT(*) FROM project_workspaces
WHERE (win_path IS NULL OR win_path = '')
AND (wsl_path IS NULL OR wsl_path = '')",
[], |row| row.get(0),
).unwrap_or(0);
QualityMetrics { dormant_projects, orphaned_projects, groups_with_inject_warn, projects_without_path }
}
/// 并行执行全部健康检查返回结构化报告内部复用入口mcp_server 也可调用)
pub fn collect_health_report() -> HealthReport {
use std::thread;
let checked_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let h_mcp = thread::spawn(check_mcp);
let h_git = thread::spawn(check_git_paths);
let h_proj = thread::spawn(check_project_paths);
let h_github = thread::spawn(check_github_token);
let h_editor = thread::spawn(check_editor);
let h_db = thread::spawn(check_db_latency);
let h_quality = thread::spawn(collect_quality_metrics);
let items = vec![
h_mcp .join().unwrap_or_else(|_| err_item("mcp", "MCP Server", "检测线程异常")),
h_git .join().unwrap_or_else(|_| err_item("git_paths", "Git 仓库路径", "检测线程异常")),
h_proj .join().unwrap_or_else(|_| err_item("project_paths", "项目本地路径", "检测线程异常")),
h_github.join().unwrap_or_else(|_| err_item("github_token", "GitHub Token", "检测线程异常")),
h_editor.join().unwrap_or_else(|_| err_item("editor", "编辑器路径", "检测线程异常")),
h_db .join().unwrap_or_else(|_| err_item("db_latency", "数据库延迟", "检测线程异常")),
];
let quality = h_quality.join().unwrap_or_default();
HealthReport { checked_at, items, quality }
}
#[tauri::command]
pub fn run_health_check() -> Result<HealthReport, String> {
Ok(collect_health_report())
}
fn expand_env(s: &str) -> String {
let mut result = s.to_string();
for (key, val) in std::env::vars() {
@ -210,3 +581,28 @@ fn expand_env(s: &str) -> String {
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn global_keys_contains_auth_and_infra() {
assert!(GLOBAL_KEYS.contains(&"github_client_id"));
assert!(GLOBAL_KEYS.contains(&"github_client_secret"));
assert!(GLOBAL_KEYS.contains(&"mcp_port"));
assert!(GLOBAL_KEYS.contains(&"wsl_distro"));
}
#[test]
fn space_specific_keys_not_in_global() {
// editor / dev_mode 是空间维度的配置,不应进入全局表
assert!(!GLOBAL_KEYS.contains(&"editor"));
assert!(!GLOBAL_KEYS.contains(&"dev_mode"));
}
#[test]
fn settings_routing_via_db() {
crate::db::conn_with_schema(); // 仅验证 schema 可正常建立,不依赖全局 pool
}
}

View File

@ -389,4 +389,113 @@ fn migrate(conn: &rusqlite::Connection) {
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create canvas_states table");
// 运行时可观测性日志(最多保留 500 条)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS runtime_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
category TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
context TEXT
);
").expect("create runtime_logs table");
}
#[cfg(test)]
pub fn conn_with_schema() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.unwrap();
migrate(&conn);
conn
}
/// 写入运行时日志。失败时静默(不能让日志写入反过来崩溃业务逻辑)
pub fn log_event(category: &str, level: &str, message: &str, context: Option<&str>) {
let pool = match POOL.get() {
Some(p) => p,
None => return,
};
let conn = match pool.get() {
Ok(c) => c,
Err(_) => return,
};
let _ = conn.execute(
"INSERT INTO runtime_logs (category, level, message, context) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![category, level, message, context],
);
// 超出 550 条时才触发清理,保留最新 500 条(避免每次写入都全表扫描)
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0))
.unwrap_or(0);
if count > 550 {
let _ = conn.execute(
"DELETE FROM runtime_logs WHERE id NOT IN (SELECT id FROM runtime_logs ORDER BY id DESC LIMIT 500)",
[],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrate_idempotent() {
// 连续两次 migrate 不应 panic
let conn = conn_with_schema();
migrate(&conn);
}
#[test]
fn core_tables_exist() {
let conn = conn_with_schema();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' \
AND name IN ('projects','settings','spaces','git_repos','runtime_logs',\
'project_profiles','project_workspaces')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 7, "缺少核心表");
}
#[test]
fn default_settings_seeded() {
let conn = conn_with_schema();
let editor: String = conn
.query_row("SELECT value FROM settings WHERE key='editor'", [], |r| r.get(0))
.unwrap();
assert_eq!(editor, "cursor");
let port: String = conn
.query_row("SELECT value FROM settings WHERE key='mcp_port'", [], |r| r.get(0))
.unwrap();
assert_eq!(port, "27190");
}
#[test]
fn runtime_logs_insert_and_count() {
let conn = conn_with_schema();
conn.execute(
"INSERT INTO runtime_logs (category, level, message) VALUES ('test','info','hello')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn log_event_silent_when_no_pool() {
// pool 未初始化时应静默返回,不 panic
log_event("test", "info", "no-pool test", None);
}
}

View File

@ -5,7 +5,7 @@ mod mcp_server;
mod models;
use git2;
use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{detect_editors, get_settings, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@ -122,6 +122,8 @@ pub fn run() {
update_settings,
detect_editors,
search_editor_path,
check_mcp_health,
run_health_check,
// project tools
get_project_tools,
add_project_tool,
@ -228,6 +230,8 @@ pub fn run() {
// canvas state
get_canvas_state,
save_canvas_state,
// mentor
get_mentor_context,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -1,4 +1,4 @@
use crate::db;
use crate::{db, db::log_event};
use serde_json::{json, Value};
use std::path::PathBuf;
@ -92,7 +92,10 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
"url": format!("http://localhost:{}/mcp/group/{}", port, group_id)
});
write_json(&path, &settings)
write_json(&path, &settings).inspect(|_| {
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
})
}
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置

View File

@ -9,7 +9,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use std::net::SocketAddr;
use crate::db;
use crate::{db, db::log_event};
/// 从 settings 表读取 MCP 端口,默认 27190
pub fn read_port() -> u16 {
@ -37,12 +37,12 @@ pub async fn start(port: u16) {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => {
println!("[MCP] 炼境 MCP Server 已启动,端口 {port}");
log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}"), None);
if let Err(e) = axum::serve(listener, app).await {
eprintln!("[MCP] Server error: {e}");
log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None);
}
}
Err(e) => eprintln!("[MCP] 绑定端口 {port} 失败: {e}"),
Err(e) => log_event("mcp_server", "error", &format!("绑定端口 {port} 失败: {e}"), None),
}
}
@ -79,7 +79,7 @@ async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -
"version": env!("CARGO_PKG_VERSION")
}
}),
"tools/list" => tools_list(),
"tools/list" => tools_list_result(),
"tools/call" => tools_call(&group_id, req.params.as_ref()).await,
_ => {
return Json(json!({
@ -96,9 +96,18 @@ async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -
// ── 工具定义 ──────────────────────────────────────────────────────────────────
fn tools_list() -> Value {
fn tools_list_result() -> Value {
json!({
"tools": [
{
"name": "get_diagnostics",
"description": "查询炼境运行时诊断信息MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "list_group_projects",
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
@ -139,6 +148,20 @@ fn tools_list() -> Value {
},
"required": ["project_id", "relative_path"]
}
},
{
"name": "get_project_mentor_context",
"description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
}
]
})
@ -159,6 +182,7 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
let gid = group_id.to_string();
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
"get_diagnostics" => get_diagnostics(),
"list_group_projects" => list_group_projects(&gid),
"get_project_blueprint" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
@ -172,6 +196,10 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
.unwrap_or("");
get_project_file(&gid, pid, rel)
}
"get_project_mentor_context" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_mentor_context(&gid, pid)
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
@ -185,6 +213,125 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
// ── 工具实现(同步,由 spawn_blocking 包裹)──────────────────────────────────
fn get_diagnostics() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let port = read_port();
// 服务启动时间(首条 mcp_server info 日志)
let start_time: Option<String> = conn.query_row(
"SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1",
[],
|r| r.get(0),
).ok();
let mut out = String::new();
out.push_str("## 炼境 MCP 诊断报告\n\n");
out.push_str(&format!("- **服务端口**: {}\n", port));
out.push_str(&format!("- **启动时间**: {}\n\n", start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)")));
// MCP 注入状态:每个 project_id 取最后一条
out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n");
let mut stmt = conn.prepare(
"SELECT context, level, message, ts
FROM runtime_logs
WHERE category = 'mcp_inject'
AND id IN (
SELECT MAX(id) FROM runtime_logs
WHERE category = 'mcp_inject'
GROUP BY json_extract(context, '$.project_id')
)
ORDER BY ts DESC
LIMIT 50",
).map_err(|e| e.to_string())?;
let inject_rows: Vec<(Option<String>, String, String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if inject_rows.is_empty() {
out.push_str("_暂无注入记录成员加入产品组时会产生_\n\n");
} else {
for (ctx, level, msg, ts) in &inject_rows {
let project_id = ctx.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let icon = if level == "info" { "" } else { "⚠️" };
out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts));
}
out.push('\n');
}
// 最近 20 条 error/warn
out.push_str("### 最近错误日志(最多 20 条)\n\n");
let mut stmt2 = conn.prepare(
"SELECT ts, category, level, message FROM runtime_logs
WHERE level IN ('error', 'warn')
ORDER BY id DESC LIMIT 20",
).map_err(|e| e.to_string())?;
let error_rows: Vec<(String, String, String, String)> = stmt2
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if error_rows.is_empty() {
out.push_str("_无错误记录_ ✅\n");
} else {
for (ts, cat, level, msg) in &error_rows {
out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg));
}
}
// ── 系统健康(复用 health check 逻辑)────────────────────────────────────
out.push_str("\n### 系统健康\n\n");
let report = crate::commands::settings::collect_health_report();
out.push_str(&format!("检查时间:{}\n\n", report.checked_at));
out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n");
for item in &report.items {
let icon = match item.status.as_str() {
"ok" => "",
"warn" => "⚠️",
"error" => "",
_ => "",
};
out.push_str(&format!(
"| {} | {} {} | {} |\n",
item.name, icon, item.status,
item.detail.as_deref().unwrap_or("-")
));
}
// ── 质量指标 ──────────────────────────────────────────────────────────────
out.push_str("\n### 质量指标\n\n");
let q = &report.quality;
out.push_str(&format!("- **休眠项目**>30天未 push{}\n", q.dormant_projects.len()));
for p in &q.dormant_projects {
out.push_str(&format!(" - {} {} 天)\n", p.name, p.days_since_push));
}
out.push_str(&format!("- **孤立项目**(无分组 & 无 repo{}\n", q.orphaned_projects.len()));
for p in &q.orphaned_projects {
out.push_str(&format!(" - {}\n", p.name));
}
out.push_str(&format!(
"- **MCP 注入异常产品组**(近 7 天):{} 个\n",
q.groups_with_inject_warn.len()
));
for gid in &q.groups_with_inject_warn {
out.push_str(&format!(" - `{}`\n", gid));
}
out.push_str(&format!("- **无本地路径项目**{}\n", q.projects_without_path));
Ok(out)
}
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
@ -287,3 +434,23 @@ fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::P
Ok(std::path::PathBuf::from(win))
}
}
fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String, String> {
// 验证项目属于该产品组(与其他 MCP 工具一致)
let conn = db::pool().get().map_err(|e| e.to_string())?;
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
Ok(ctx.mentor_markdown)
}

View File

@ -6,6 +6,7 @@ import { ProjectModal } from "./components/dashboard/ProjectModal";
import { DevToolsPage } from "./components/devtools/DevToolsPage";
import { ProxyPage } from "./components/proxy/ProxyPage";
import { SettingsPage } from "./components/settings/SettingsPage";
import { HealthPage } from "./components/health/HealthPage";
import { Sidebar, type Page } from "./components/layout/Sidebar";
import { Toast } from "./components/ui/Toast";
import LoginPage from "./components/auth/LoginPage";
@ -69,6 +70,7 @@ export default function App() {
{page === "devtools" && <DevToolsPage />}
{page === "proxy" && <ProxyPage />}
{page === "settings" && <SettingsPage />}
{page === "health" && <HealthPage />}
</main>
<ProjectModal />

View File

@ -9,6 +9,7 @@ import { CicdModal } from "./CicdModal";
import { PublishModal } from "./PublishModal";
import { BlueprintModal } from "./BlueprintModal";
import { BlueprintPromptModal } from "./BlueprintPromptModal";
import { MentorPanel } from "../mentor/MentorPanel";
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener";
@ -64,6 +65,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
const [showPublish, setShowPublish] = useState(false);
const [showBlueprint, setShowBlueprint] = useState(false);
const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
const [showMentor, setShowMentor] = useState(false);
const [showBranchMenu, setShowBranchMenu] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [creatingBranch, setCreatingBranch] = useState(false);
@ -685,6 +687,10 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center justify-between">
<span className="text-[11px] text-gray-300">{(p.updated_at ?? "").slice(0, 10)}</span>
<div className="flex items-center gap-1.5">
<button onClick={() => setShowMentor(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="项目导师">
🎓
</button>
<button onClick={() => setShowBlueprint(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="项目蓝图">
🗺
@ -772,6 +778,14 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
/>
)}
{showMentor && (
<MentorPanel
projectId={p.id}
projectName={p.name}
onClose={() => setShowMentor(false)}
/>
)}
{/* Expanded activity */}
{expanded && (
<div className="border-t border-gray-50 pt-3">

View File

@ -0,0 +1,276 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { runHealthCheck, type HealthItem, type HealthReport } from "../../lib/commands";
// ── 状态图标 ──────────────────────────────────────────────────────────────────
function StatusIcon({ status }: { status: HealthItem["status"] }) {
if (status === "ok") return (
<svg className="w-4 h-4 text-green-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"/>
</svg>
);
if (status === "warn") return (
<svg className="w-4 h-4 text-yellow-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zm-1.763-.707c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.543-2.575L6.457 1.047zM9 11a1 1 0 11-2 0 1 1 0 012 0zm-.25-5.25a.75.75 0 00-1.5 0v2.5a.75.75 0 001.5 0v-2.5z"/>
</svg>
);
if (status === "error") return (
<svg className="w-4 h-4 text-red-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.343 13.657A8 8 0 1113.657 2.343 8 8 0 012.343 13.657zM6.03 4.97a.75.75 0 00-1.06 1.06L6.94 8 4.97 9.97a.75.75 0 101.06 1.06L8 9.06l1.97 1.97a.75.75 0 101.06-1.06L9.06 8l1.97-1.97a.75.75 0 10-1.06-1.06L8 6.94 6.03 4.97z"/>
</svg>
);
return (
<svg className="w-4 h-4 text-gray-300 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zM5 8a.75.75 0 01.75-.75h4.5a.75.75 0 010 1.5h-4.5A.75.75 0 015 8z"/>
</svg>
);
}
const STATUS_BG: Record<HealthItem["status"], string> = {
ok: "bg-green-50 border-green-100",
warn: "bg-yellow-50 border-yellow-100",
error: "bg-red-50 border-red-100",
skip: "bg-gray-50 border-gray-100",
};
// ── 诊断报告生成 ──────────────────────────────────────────────────────────────
const escMd = (s: string) => s.replace(/\|/g, "\\|");
function buildMarkdownReport(report: HealthReport): string {
const statusEmoji: Record<string, string> = { ok: "✅", warn: "⚠️", error: "❌", skip: "—" };
const lines: string[] = [
"# 炼境健康诊断报告",
`检查时间:${report.checked_at}`,
"",
"## 基础设施健康",
"| 检查项 | 状态 | 详情 |",
"|--------|------|------|",
...report.items.map(i =>
`| ${escMd(i.name)} | ${statusEmoji[i.status] ?? i.status} | ${escMd(i.detail ?? "-")} |`
),
"",
"## 质量指标",
];
const q = report.quality;
lines.push(`- 休眠项目(>30天未 push${q.dormant_projects.length}`);
q.dormant_projects.forEach(p => lines.push(` - ${p.name}${p.days_since_push} 天)`));
lines.push(`- 孤立项目(无分组 & 无 repo${q.orphaned_projects.length}`);
q.orphaned_projects.forEach(p => lines.push(` - ${p.name}`));
lines.push(`- 注入异常产品组(近 7 天):${q.groups_with_inject_warn.length}`);
lines.push(`- 无本地路径项目:${q.projects_without_path}`);
return lines.join("\n");
}
// ── 主页面 ────────────────────────────────────────────────────────────────────
export function HealthPage() {
const [copied, setCopied] = useState(false);
const { data: report, isFetching, refetch, dataUpdatedAt } = useQuery({
queryKey: ["health-check"],
queryFn: runHealthCheck,
staleTime: Infinity,
refetchOnWindowFocus: false,
});
const handleCopyReport = async () => {
if (!report) return;
try {
await navigator.clipboard.writeText(buildMarkdownReport(report));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
alert("复制失败,请手动选择文本后复制");
}
};
const errorCount = report?.items.filter(i => i.status === "error").length ?? 0;
const warnCount = report?.items.filter(i => i.status === "warn").length ?? 0;
const lastChecked = dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleTimeString("zh-CN") : null;
return (
<div className="flex flex-col h-full">
{/* 顶栏 */}
<div className="border-b border-gray-100 bg-white px-6 py-3 flex items-center gap-3 shrink-0">
<h1 className="text-sm font-semibold text-gray-800"></h1>
{lastChecked && (
<span className="text-xs text-gray-400"> {lastChecked}</span>
)}
<div className="flex-1" />
{report && (
<button
onClick={handleCopyReport}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
</svg>
{copied ? "已复制" : "生成诊断报告"}
</button>
)}
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60 transition-colors"
>
<svg className={`w-3.5 h-3.5 ${isFetching ? "animate-spin" : ""}`} viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 11-1.49.178A5.501 5.501 0 008 2.5zM1.705 8.005a.75.75 0 01.834.656 5.501 5.501 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834z"/>
</svg>
{isFetching ? "检查中…" : "立即检查"}
</button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-auto p-6">
{!report && !isFetching && (
<div className="flex flex-col items-center justify-center h-64 text-gray-400 gap-3">
<svg className="w-10 h-10 text-gray-200" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zM1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"/>
</svg>
<p className="text-sm"></p>
</div>
)}
{isFetching && !report && (
<div className="flex flex-col items-center justify-center h-64 text-gray-400 gap-3">
<svg className="w-8 h-8 animate-spin text-blue-400" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 11-1.49.178A5.501 5.501 0 008 2.5z"/>
</svg>
<p className="text-sm"></p>
</div>
)}
{report && (
<div className="grid grid-cols-2 gap-6 max-w-5xl">
{/* 左列:基础设施健康项 */}
<div className="space-y-3">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide"></h2>
{errorCount > 0 && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-600">{errorCount} </span>
)}
{errorCount === 0 && warnCount > 0 && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-100 text-yellow-600">{warnCount} </span>
)}
{errorCount === 0 && warnCount === 0 && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-600"></span>
)}
</div>
{report.items.map((item) => (
<div
key={item.id}
className={`rounded-xl border p-3.5 ${STATUS_BG[item.status]}`}
>
<div className="flex items-center gap-2.5">
<StatusIcon status={item.status} />
<span className="text-sm font-medium text-gray-700">{item.name}</span>
</div>
{item.detail && (
<p className="mt-1.5 ml-7 text-xs text-gray-500 leading-relaxed">{item.detail}</p>
)}
</div>
))}
</div>
{/* 右列:质量指标 */}
<div className="space-y-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-4"></h2>
{/* 休眠项目 */}
<QualityCard
title="休眠项目"
subtitle="> 30 天未 push 且有 repo"
count={report.quality.dormant_projects.length}
countColor={report.quality.dormant_projects.length > 0 ? "text-yellow-600" : "text-green-600"}
>
{report.quality.dormant_projects.length > 0 && (
<ul className="mt-2 space-y-1">
{report.quality.dormant_projects.map(p => (
<li key={p.id} className="flex items-center justify-between text-xs text-gray-500">
<span className="truncate">{p.name}</span>
<span className="shrink-0 ml-2 text-yellow-500 font-medium">{p.days_since_push} </span>
</li>
))}
</ul>
)}
</QualityCard>
{/* 孤立项目 */}
<QualityCard
title="孤立项目"
subtitle="无分组 & 无 repo_url"
count={report.quality.orphaned_projects.length}
countColor={report.quality.orphaned_projects.length > 0 ? "text-yellow-600" : "text-green-600"}
>
{report.quality.orphaned_projects.length > 0 && (
<ul className="mt-2 space-y-1">
{report.quality.orphaned_projects.map(p => (
<li key={p.id} className="text-xs text-gray-500 truncate">{p.name}</li>
))}
</ul>
)}
</QualityCard>
{/* 注入异常组 */}
<QualityCard
title="MCP 注入异常"
subtitle="近 7 天有 error/warn 的产品组"
count={report.quality.groups_with_inject_warn.length}
countColor={report.quality.groups_with_inject_warn.length > 0 ? "text-red-600" : "text-green-600"}
>
{report.quality.groups_with_inject_warn.length > 0 && (
<ul className="mt-2 space-y-1">
{report.quality.groups_with_inject_warn.map(id => (
<li key={id} className="text-xs text-gray-400 font-mono truncate">{id}</li>
))}
</ul>
)}
</QualityCard>
{/* 无路径项目数 */}
<QualityCard
title="无本地路径项目"
subtitle="win_path 和 wsl_path 均为空"
count={report.quality.projects_without_path}
countColor={report.quality.projects_without_path > 0 ? "text-yellow-600" : "text-green-600"}
/>
</div>
</div>
)}
</div>
</div>
);
}
// ── 质量指标卡片 ──────────────────────────────────────────────────────────────
function QualityCard({
title, subtitle, count, countColor, children,
}: {
title: string;
subtitle: string;
count: number;
countColor: string;
children?: React.ReactNode;
}) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-3.5">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-gray-700">{title}</p>
<p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>
</div>
<span className={`text-2xl font-bold tabular-nums shrink-0 ${countColor}`}>{count}</span>
</div>
{children}
</div>
);
}

View File

@ -8,13 +8,14 @@ import {
import { useUIStore } from "../../store/ui";
import { RepoRegistryModal } from "../dashboard/RepoRegistryModal";
export type Page = "overview" | "dashboard" | "devtools" | "proxy" | "settings";
export type Page = "overview" | "dashboard" | "devtools" | "proxy" | "settings" | "health";
const NAV_ITEMS: { id: Page; label: string }[] = [
{ id: "overview", label: "仪表盘" },
{ id: "dashboard", label: "项目管理" },
{ id: "devtools", label: "开发环境" },
{ id: "proxy", label: "WSL 代理" },
{ id: "health", label: "健康中心" },
{ id: "settings", label: "设置" },
];

View File

@ -455,7 +455,10 @@ function AddMemberModal({ group, projects, existingIds, onClose, onSave }: {
const save = async () => {
if (!selected) return;
try {
await addGroupMember(selected, group.id, role || undefined);
const warning = await addGroupMember(selected, group.id, role || undefined);
if (warning) {
showToast(`成员已添加,但 MCP 注入失败:${warning}`);
}
onSave();
} catch (e) {
showToast(`❌ 添加失败: ${e}`);

View File

@ -0,0 +1,206 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getMentorContext, type MentorContext } from "../../lib/commands";
interface Props {
projectId: string;
projectName: string;
onClose: () => void;
}
export function MentorPanel({ projectId, projectName, onClose }: Props) {
const [copied, setCopied] = useState(false);
const { data: ctx, isLoading, isError, error } = useQuery<MentorContext>({
queryKey: ["mentorContext", projectId],
queryFn: () => getMentorContext(projectId),
staleTime: 30000,
retry: false,
});
const handleCopy = () => {
if (!ctx) return;
navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-base font-semibold text-gray-900"></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 transition-colors p-1"
aria-label="关闭"
>
<svg className="w-5 h-5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-5">
{isLoading && (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">
</div>
)}
{isError && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
<span></span>
<span>{String(error)}</span>
</div>
)}
{ctx && (
<div className="flex flex-col gap-5">
{/* 本地健康 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
<div className="flex flex-col gap-1.5">
<HealthRow
ok={ctx.health.path_valid}
label="本地路径"
ok_text="有效"
fail_text="路径不存在"
/>
<HealthRow
ok={ctx.health.has_git}
label="Git 仓库"
ok_text="已初始化"
fail_text="未找到 .git 目录"
warn={!ctx.health.has_git}
/>
{ctx.health.tech_stack && (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400 w-16 shrink-0"></span>
<span className="text-gray-700">{ctx.health.tech_stack}</span>
</div>
)}
{ctx.health.last_commit && (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400 w-16 shrink-0"></span>
<code className="text-gray-700 font-mono text-[11px] bg-gray-50 px-1.5 py-0.5 rounded">
{ctx.health.last_commit}
</code>
</div>
)}
</div>
</section>
{/* 蓝图状态 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
{ctx.blueprint_summary ? (
ctx.blueprint_summary.module_count === 0 ? (
<p className="text-xs text-gray-400"></p>
) : (
<div className="grid grid-cols-3 gap-2">
{[
{ label: "模块", value: `${ctx.blueprint_summary.done_count}/${ctx.blueprint_summary.module_count}` },
{ label: "任务", value: `${ctx.blueprint_summary.done_tasks}/${ctx.blueprint_summary.total_tasks}` },
{ label: "可派发", value: String(ctx.blueprint_summary.dispatchable) },
{ label: "进行中", value: String(ctx.blueprint_summary.in_progress) },
{ label: "已规划", value: String(ctx.blueprint_summary.planned) },
].map(({ label, value }) => (
<div key={label} className="bg-gray-50 rounded-lg px-3 py-2 flex flex-col gap-0.5">
<span className="text-[11px] text-gray-400">{label}</span>
<span className="text-sm font-semibold text-gray-800">{value}</span>
</div>
))}
</div>
)
) : (
<p className="text-xs text-gray-400"> .blueprint/manifest.yaml</p>
)}
</section>
{/* 近期错误日志 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
{ctx.recent_errors.length === 0 ? (
<p className="text-xs text-gray-400"> </p>
) : (
<ul className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{ctx.recent_errors.map((e, i) => (
<li key={i} className="flex gap-2 text-[11px]">
<span className={`shrink-0 font-semibold ${e.level === "error" ? "text-red-500" : "text-yellow-500"}`}>
{e.level === "error" ? "❌" : "⚠️"}
</span>
<span className="text-gray-400 shrink-0 font-mono">{e.ts.slice(0, 19)}</span>
<span className="text-gray-500 shrink-0">[{e.category}]</span>
<span className="text-gray-700 break-all">{e.message}</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
</div>
{/* Footer */}
{ctx && (
<div className="px-6 py-4 border-t border-gray-100 shrink-0 flex items-center justify-between">
<p className="text-xs text-gray-400"> Claude</p>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors shrink-0"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
</>
) : (
<>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
</svg>
</>
)}
</button>
</div>
)}
</div>
</div>
);
}
// ── 辅助组件 ──────────────────────────────────────────────────────────────────
function HealthRow({
ok,
label,
ok_text,
fail_text,
warn,
}: {
ok: boolean;
label: string;
ok_text: string;
fail_text: string;
warn?: boolean;
}) {
const icon = ok ? "✅" : warn ? "⚠️" : "❌";
const textColor = ok ? "text-green-700" : warn ? "text-yellow-700" : "text-red-700";
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400 w-16 shrink-0">{label}</span>
<span>{icon}</span>
<span className={textColor}>{ok ? ok_text : fail_text}</span>
</div>
);
}

View File

@ -6,8 +6,8 @@ import { relaunch } from "@tauri-apps/plugin-process";
import { getVersion } from "@tauri-apps/api/app";
import {
detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros,
getProjects, syncBlueprintRules,
type EditorCandidate, type SyncResult,
getProjects, syncBlueprintRules, checkMcpHealth,
type EditorCandidate, type SyncResult, type McpHealthResult,
} from "../../lib/commands";
// GitHub OAuth 配置已移至侧边栏「应用设置」
import { useUIStore } from "../../store/ui";
@ -193,6 +193,8 @@ export function SettingsPage() {
const [wslError, setWslError] = useState("");
const [formTarget, setFormTarget] = useState<EditorEntry | null | undefined>(undefined);
const [mcpPort, setMcpPort] = useState("27190");
const [mcpHealth, setMcpHealth] = useState<McpHealthResult | null>(null);
const [mcpChecking, setMcpChecking] = useState(false);
useEffect(() => {
if (settings) {
@ -207,6 +209,8 @@ export function SettingsPage() {
setWslDistro(settings.wsl_distro ?? "Ubuntu");
setWslOpenMode(settings.wsl_open_mode ?? "wsl_internal");
setMcpPort(settings.mcp_port ?? "27190");
// 设置加载后自动检查一次 MCP 健康状态
pingMcp();
}
}, [settings]);
@ -255,6 +259,18 @@ export function SettingsPage() {
showToast("已保存 ✓");
};
const pingMcp = async () => {
setMcpChecking(true);
try {
const result = await checkMcpHealth();
setMcpHealth(result);
} catch {
setMcpHealth({ ok: false, port: parseInt(mcpPort, 10), error: "检测失败" });
} finally {
setMcpChecking(false);
}
};
const saveMcp = async () => {
const port = parseInt(mcpPort, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
@ -264,6 +280,7 @@ export function SettingsPage() {
await updateSettings({ mcp_port: mcpPort });
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
showToast("已保存,所有产品组成员配置已更新 ✓");
pingMcp();
};
const detectWsl = async () => {
@ -473,10 +490,22 @@ export function SettingsPage() {
<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>
{mcpChecking ? (
<span className="text-xs text-gray-400"></span>
) : mcpHealth ? (
mcpHealth.ok ? (
<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" />
{mcpHealth.latency_ms}ms
</span>
) : (
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs bg-red-50 text-red-600 border border-red-200" title={mcpHealth.error}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" />
</span>
)
) : null}
<button onClick={pingMcp} className="text-xs text-gray-400 hover:text-gray-600"></button>
</div>
<p className="text-xs text-gray-400">
MCP Server Claude

View File

@ -127,7 +127,7 @@ export const reorderGroups = (ids: string[]) => invoke<void>("reorder_groups", {
export const getGroupMaps = () => invoke<ProjectGroupMap[]>("get_group_maps");
export const addGroupMember = (projectId: string, groupId: string, role?: string) =>
invoke<void>("add_group_member", { projectId, groupId, role });
invoke<string | null>("add_group_member", { projectId, groupId, role });
export const removeGroupMember = (projectId: string, groupId: string) =>
invoke<void>("remove_group_member", { projectId, groupId });
@ -145,6 +145,48 @@ export const detectEditors = () => invoke<EditorCandidate[]>("detect_editors");
export const searchEditorPath = (cmd: string) =>
invoke<string | null>("search_editor_path", { cmd });
export interface McpHealthResult {
ok: boolean;
port: number;
latency_ms?: number;
error?: string;
}
export const checkMcpHealth = () => invoke<McpHealthResult>("check_mcp_health");
export interface HealthItem {
id: string;
name: string;
status: "ok" | "warn" | "error" | "skip";
detail?: string;
}
export interface DormantProject {
id: string;
name: string;
days_since_push: number;
}
export interface OrphanedProject {
id: string;
name: string;
}
export interface QualityMetrics {
dormant_projects: DormantProject[];
orphaned_projects: OrphanedProject[];
groups_with_inject_warn: string[];
projects_without_path: number;
}
export interface HealthReport {
checked_at: string;
items: HealthItem[];
quality: QualityMetrics;
}
export const runHealthCheck = () => invoke<HealthReport>("run_health_check");
// ── Project Tools ─────────────────────────────────────────────────────────────
export interface ProjectTool {
@ -1014,3 +1056,41 @@ export const getCanvasState = (groupId: string) =>
export const saveCanvasState = (groupId: string, data: string) =>
invoke<void>("save_canvas_state", { groupId, data });
// ── Mentor ────────────────────────────────────────────────────────────────────
export interface ProjectHealth {
path_valid: boolean;
has_git: boolean;
last_commit?: string;
tech_stack?: string;
}
export interface BlueprintSummary {
module_count: number;
done_count: number;
in_progress: number;
planned: number;
total_tasks: number;
done_tasks: number;
dispatchable: number;
}
export interface RuntimeLogEntry {
ts: string;
category: string;
level: string;
message: string;
}
export interface MentorContext {
project_id: string;
project_name: string;
health: ProjectHealth;
blueprint_summary?: BlueprintSummary;
recent_errors: RuntimeLogEntry[];
mentor_markdown: string;
}
export const getMentorContext = (projectId: string) =>
invoke<MentorContext>("get_mentor_context", { projectId });