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:
parent
c598c9b05b
commit
eb346ae1fd
@ -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/` 会随蓝图一并分发,无需手动安装。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要删除已完成的任务卡,它们是项目历史的一部分
|
||||
|
||||
@ -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
|
||||
|
||||
90
.blueprint/modules/health-center.md
Normal file
90
.blueprint/modules/health-center.md
Normal 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,不阻塞整体
|
||||
- 质量指标从已有数据计算,不引入新的数据采集,保持实现简单
|
||||
82
.blueprint/modules/project-mentor.md
Normal file
82
.blueprint/modules/project-mentor.md
Normal 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 · 后端聚合 command(MentorContext)
|
||||
- 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 完成,避免在数据基础未就绪时过早实现上层
|
||||
82
.blueprint/modules/runtime-diagnostics.md
Normal file
82
.blueprint/modules/runtime-diagnostics.md
Normal 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.rs:inject/remove 成功/失败 → log_event("mcp_inject", "info"/"error", context 含 project_id + group_id)
|
||||
- commands/groups.rs:add_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 而非 JSON,Claude 读取更自然,无需额外解析
|
||||
- T4 只改 add_group_member,remove 失败影响较小(幂等清理),不做 UI 反馈
|
||||
- 表上限 500 条:足够覆盖数天的操作记录,不会无限膨胀
|
||||
@ -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
15
.claude/settings.json
Normal 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){}});\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
62
.claude/skills/architect/SKILL.md
Normal file
62
.claude/skills/architect/SKILL.md
Normal 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 能直接产出可派发任务卡
|
||||
- 需求不清晰时先向用户确认,再开始分析
|
||||
65
.claude/skills/catchup/SKILL.md
Normal file
65
.claude/skills/catchup/SKILL.md
Normal 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 秒内决定下一步
|
||||
82
.claude/skills/splitter/SKILL.md
Normal file
82
.claude/skills/splitter/SKILL.md
Normal 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
213
AI-WORKFLOW.md
Normal 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"类需求 |
|
||||
37
CLAUDE.md
37
CLAUDE.md
@ -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" -->
|
||||
## 项目蓝图
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
425
src-tauri/src/commands/mentor.rs
Normal file
425
src-tauri/src/commands/mentor.rs
Normal 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
|
||||
}
|
||||
@ -18,3 +18,4 @@ pub mod tags;
|
||||
pub mod cicd;
|
||||
pub mod blueprint;
|
||||
pub mod canvas;
|
||||
pub mod mentor;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 配置
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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">
|
||||
|
||||
276
src/components/health/HealthPage.tsx
Normal file
276
src/components/health/HealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: "设置" },
|
||||
];
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
206
src/components/mentor/MentorPanel.tsx
Normal file
206
src/components/mentor/MentorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 提供跨项目上下文能力。配置好产品组成员后,每个项目的
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user