diff --git a/.blueprint/CONVENTIONS.md b/.blueprint/CONVENTIONS.md index 87b2ca4..b7e2299 100644 --- a/.blueprint/CONVENTIONS.md +++ b/.blueprint/CONVENTIONS.md @@ -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/.md` +2. 按以下原则拆解: + - **粒度**:每张卡 complexity 必须是 S 或 M,不允许 L + - **依赖顺序**:数据层 → 后端命令 → 前端封装 → UI 组件 + - **可派发标准**:每张卡必须有 `files` + `acceptance` + `complexity` +3. 将任务卡追加到 `modules/.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/` 会随蓝图一并分发,无需手动安装。 + +--- + ## 注意事项 - 不要删除已完成的任务卡,它们是项目历史的一部分 diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 94ed9f5..2824bac 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -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 diff --git a/.blueprint/modules/health-center.md b/.blueprint/modules/health-center.md new file mode 100644 index 0000000..9022cc9 --- /dev/null +++ b/.blueprint/modules/health-center.md @@ -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 }` + - `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,不阻塞整体 +- 质量指标从已有数据计算,不引入新的数据采集,保持实现简单 diff --git a/.blueprint/modules/project-mentor.md b/.blueprint/modules/project-mentor.md new file mode 100644 index 0000000..ee0ebd1 --- /dev/null +++ b/.blueprint/modules/project-mentor.md @@ -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 完成,避免在数据基础未就绪时过早实现上层 diff --git a/.blueprint/modules/runtime-diagnostics.md b/.blueprint/modules/runtime-diagnostics.md new file mode 100644 index 0000000..df1057c --- /dev/null +++ b/.blueprint/modules/runtime-diagnostics.md @@ -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 }` + - 前端 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 }` + - 设置页 MCP 区块:挂载时自动调用一次,显示真实状态(绿色"运行中 {latency}ms" / 红色"端口未响应: {error}") + - 端口保存后自动重新检查 + +## 决策记录 + +- runtime_logs 存 SQLite 而非内存,炼境重启后仍可查历史注入记录 +- get_diagnostics 返回 Markdown 而非 JSON,Claude 读取更自然,无需额外解析 +- T4 只改 add_group_member,remove 失败影响较小(幂等清理),不做 UI 反馈 +- 表上限 500 条:足够覆盖数天的操作记录,不会无限膨胀 diff --git a/.blueprint/usage.json b/.blueprint/usage.json index 081436a..1ae631d 100644 --- a/.blueprint/usage.json +++ b/.blueprint/usage.json @@ -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 + } } ] } \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..768fdaf --- /dev/null +++ b/.claude/settings.json @@ -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){}});\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/architect/SKILL.md b/.claude/skills/architect/SKILL.md new file mode 100644 index 0000000..285df33 --- /dev/null +++ b/.claude/skills/architect/SKILL.md @@ -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/.md` 骨架 + +## 原则 +- **不写任何代码**,只输出设计文档 +- 设计文档要足够具体,让 splitter 能直接产出可派发任务卡 +- 需求不清晰时先向用户确认,再开始分析 diff --git a/.claude/skills/catchup/SKILL.md b/.claude/skills/catchup/SKILL.md new file mode 100644 index 0000000..064ecdf --- /dev/null +++ b/.claude/skills/catchup/SKILL.md @@ -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) +... + +## 阻塞中(🔴) +- <任务卡标题>: +``` + +若没有中断任务,直接列出可派发任务和建议的下一步。 + +## 原则 + +- **不写任何代码**,只做状态诊断 +- 中断任务的判断依据是代码实际状态,不是任务卡写的 +- 若 🔵 任务的代码已经全部实现,直接说"可以关锁(标 ✅)" +- 输出要短,能让用户 30 秒内决定下一步 diff --git a/.claude/skills/splitter/SKILL.md b/.claude/skills/splitter/SKILL.md new file mode 100644 index 0000000..8e3f956 --- /dev/null +++ b/.claude/skills/splitter/SKILL.md @@ -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/.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` +- [ ] 任务卡按执行顺序排列 diff --git a/AI-WORKFLOW.md b/AI-WORKFLOW.md new file mode 100644 index 0000000..d0464b5 --- /dev/null +++ b/AI-WORKFLOW.md @@ -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/.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/.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"类需求 | diff --git a/CLAUDE.md b/CLAUDE.md index 9413df6..367f535 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,28 +2,7 @@ Git 多空间开发管理器,Tauri v2 + React + TypeScript + Rust。 -## 项目蓝图 - -本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 - -**每次对话都应遵循以下流程:** - -### 1. 了解当前状态 -对话开始时,先读取 `.blueprint/manifest.yaml` 了解项目模块和状态。 - -### 2. 讨论需求时更新蓝图 -- 确认了新需求 → 在 manifest.yaml 新增模块,创建 `.blueprint/modules/.md` -- 拆分了大需求 → 在模块文件中添加任务卡 -- 调整了方向 → 更新模块关系(edges) - -### 3. 完成实现后标记蓝图 -- 完成了功能 → 更新对应任务卡状态为 `done`(前缀改为 ✅) -- 更新 manifest.yaml 顶部的 `updated` 日期 - -### 4. 任务卡规范 -详见 `.blueprint/CONVENTIONS.md`,核心规则: -- 任务卡同时具备 `files` + `acceptance` + complexity `S/M` → 标记为 📋 可派发 -- 可派发的任务卡可以直接交给 Claude Agent 独立完成 + ## 技术栈 - 前端: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 + ## 项目蓝图 diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index a4d1255..3f2bea6 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1031,13 +1031,39 @@ fn read_manifest_updated(path: &Path) -> Option { } fn is_content_stale(updated: &Option) -> 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 { diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 1d2ce21..3e6f60c 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -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, String> { } #[tauri::command] -pub fn add_group_member(project_id: String, group_id: String, role: Option) -> Result<(), String> { +pub fn add_group_member(project_id: String, group_id: String, role: Option) -> Result, 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 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", diff --git a/src-tauri/src/commands/mentor.rs b/src-tauri/src/commands/mentor.rs new file mode 100644 index 0000000..55ce970 --- /dev/null +++ b/src-tauri/src/commands/mentor.rs @@ -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, + /// 项目记录的技术栈 + pub tech_stack: Option, +} + +#[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, + pub recent_errors: Vec, + /// 格式化好的 Markdown,可直接复制给 Claude + pub mentor_markdown: String, +} + +// ── Tauri command ───────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn get_mentor_context(project_id: String) -> Result { + let conn = pool().get().map_err(|e| e.to_string())?; + + // 1. 从 DB 读取项目基本信息 + let row: rusqlite::Result<(String, Option, Option, Option, Option)> = 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, Option) = 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 -- test -d ` 检查 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 { + 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 = 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 = 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 { + 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\\... 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 { + 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 { + 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 { + if !root.join(".blueprint").join("manifest.yaml").exists() { + return None; + } + parse_blueprint_summary(root) +} + +fn parse_blueprint_summary(root: &Path) -> Option { + 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 { + 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::>()) + }); + + result.unwrap_or_default() +} + +// ── Markdown 生成 ───────────────────────────────────────────────────────────── + +fn build_mentor_markdown( + project_id: &str, + project_name: &str, + health: &ProjectHealth, + blueprint: &Option, + 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 +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 881cf47..9d55c36 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,3 +18,4 @@ pub mod tags; pub mod cicd; pub mod blueprint; pub mod canvas; +pub mod mentor; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 0c7899e..e6106a4 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -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, space_id: Option Result, String> { Ok(None) } +/// 检测 MCP Server 是否真正在监听(实际 HTTP ping) +#[tauri::command] +pub fn check_mcp_health() -> Result { + 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, +} + +#[derive(Debug, Serialize)] +pub struct HealthReport { + pub checked_at: String, + pub items: Vec, + 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 = (|| { + 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 = 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 = (|| { + 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 = 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 = (|| { + let conn = pool().get().map_err(|e| e.to_string())?; + let token: Option = 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::().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 = (|| { + let conn = pool().get().map_err(|e| e.to_string())?; + let raw: Option = 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 = (|| { + 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, + /// 未加入任何产品组且无 repo_url 的项目 + pub orphaned_projects: Vec, + /// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表 + pub groups_with_inject_warn: Vec, + /// 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 = { + 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 = { + 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 = { + 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 = 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 = contexts.iter() + .filter_map(|ctx| { + serde_json::from_str::(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 { + 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 + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9e49cb1..5600871 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b392fc5..f7392ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 869a619..497f0cd 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -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 配置 diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index bf389fe..423d725 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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, Json(req): Json) - "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, Json(req): Json) - // ── 工具定义 ────────────────────────────────────────────────────────────────── -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 { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let port = read_port(); + + // 服务启动时间(首条 mcp_server info 日志) + let start_time: Option = 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)> = 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::(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 { 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 Result { + // 验证项目属于该产品组(与其他 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) +} diff --git a/src/App.tsx b/src/App.tsx index ad3357a..aa82d8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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" && } {page === "proxy" && } {page === "settings" && } + {page === "health" && } diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx index 944e6fc..f3728b3 100644 --- a/src/components/dashboard/ProjectCard.tsx +++ b/src/components/dashboard/ProjectCard.tsx @@ -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) {
{(p.updated_at ?? "").slice(0, 10)}
+ + )} + +
+ + {/* 内容区 */} +
+ {!report && !isFetching && ( +
+ + + +

点击「立即检查」开始诊断

+
+ )} + + {isFetching && !report && ( +
+ + + +

正在并行检查各项状态…

+
+ )} + + {report && ( +
+ {/* 左列:基础设施健康项 */} +
+
+

基础设施健康

+ {errorCount > 0 && ( + {errorCount} 项异常 + )} + {errorCount === 0 && warnCount > 0 && ( + {warnCount} 项警告 + )} + {errorCount === 0 && warnCount === 0 && ( + 全部正常 + )} +
+ + {report.items.map((item) => ( +
+
+ + {item.name} +
+ {item.detail && ( +

{item.detail}

+ )} +
+ ))} +
+ + {/* 右列:质量指标 */} +
+

质量指标

+ + {/* 休眠项目 */} + 0 ? "text-yellow-600" : "text-green-600"} + > + {report.quality.dormant_projects.length > 0 && ( +
    + {report.quality.dormant_projects.map(p => ( +
  • + {p.name} + {p.days_since_push} 天 +
  • + ))} +
+ )} +
+ + {/* 孤立项目 */} + 0 ? "text-yellow-600" : "text-green-600"} + > + {report.quality.orphaned_projects.length > 0 && ( +
    + {report.quality.orphaned_projects.map(p => ( +
  • {p.name}
  • + ))} +
+ )} +
+ + {/* 注入异常组 */} + 0 ? "text-red-600" : "text-green-600"} + > + {report.quality.groups_with_inject_warn.length > 0 && ( +
    + {report.quality.groups_with_inject_warn.map(id => ( +
  • {id}
  • + ))} +
+ )} +
+ + {/* 无路径项目数 */} + 0 ? "text-yellow-600" : "text-green-600"} + /> +
+
+ )} +
+
+ ); +} + +// ── 质量指标卡片 ────────────────────────────────────────────────────────────── + +function QualityCard({ + title, subtitle, count, countColor, children, +}: { + title: string; + subtitle: string; + count: number; + countColor: string; + children?: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {count} +
+ {children} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index aa4dc0a..54461d3 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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: "设置" }, ]; diff --git a/src/components/manage/ManagePage.tsx b/src/components/manage/ManagePage.tsx index 787d1ed..2f8948b 100644 --- a/src/components/manage/ManagePage.tsx +++ b/src/components/manage/ManagePage.tsx @@ -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}`); diff --git a/src/components/mentor/MentorPanel.tsx b/src/components/mentor/MentorPanel.tsx new file mode 100644 index 0000000..ecf4d49 --- /dev/null +++ b/src/components/mentor/MentorPanel.tsx @@ -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({ + 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 ( +
+
+ {/* Header */} +
+
+

项目导师

+

{projectName}

+
+ +
+ + {/* Body */} +
+ {isLoading && ( +
+ 加载中… +
+ )} + + {isError && ( +
+ + {String(error)} +
+ )} + + {ctx && ( +
+ {/* 本地健康 */} +
+

本地健康

+
+ + + {ctx.health.tech_stack && ( +
+ 技术栈 + {ctx.health.tech_stack} +
+ )} + {ctx.health.last_commit && ( +
+ 最近提交 + + {ctx.health.last_commit} + +
+ )} +
+
+ + {/* 蓝图状态 */} +
+

蓝图状态

+ {ctx.blueprint_summary ? ( + ctx.blueprint_summary.module_count === 0 ? ( +

蓝图存在,但暂无模块数据。

+ ) : ( +
+ {[ + { 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 }) => ( +
+ {label} + {value} +
+ ))} +
+ ) + ) : ( +

该项目尚未接入蓝图(无 .blueprint/manifest.yaml)。

+ )} +
+ + {/* 近期错误日志 */} +
+

近期错误日志

+ {ctx.recent_errors.length === 0 ? ( +

无错误记录 ✅

+ ) : ( +
    + {ctx.recent_errors.map((e, i) => ( +
  • + + {e.level === "error" ? "❌" : "⚠️"} + + {e.ts.slice(0, 19)} + [{e.category}] + {e.message} +
  • + ))} +
+ )} +
+
+ )} +
+ + {/* Footer */} + {ctx && ( +
+

生成导师报告后粘贴给 Claude,即可获得质量分析和成长建议

+ +
+ )} +
+
+ ); +} + +// ── 辅助组件 ────────────────────────────────────────────────────────────────── + +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 ( +
+ {label} + {icon} + {ok ? ok_text : fail_text} +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 0e64f99..2900754 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -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(undefined); const [mcpPort, setMcpPort] = useState("27190"); + const [mcpHealth, setMcpHealth] = useState(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() {

MCP 服务

- - - 运行中 - + {mcpChecking ? ( + 检测中… + ) : mcpHealth ? ( + mcpHealth.ok ? ( + + + 运行中 {mcpHealth.latency_ms}ms + + ) : ( + + + 端口未响应 + + ) + ) : null} +

产品组 MCP Server,为组内项目的 Claude 提供跨项目上下文能力。配置好产品组成员后,每个项目的 diff --git a/src/lib/commands.ts b/src/lib/commands.ts index daa11b7..6b32b2c 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -127,7 +127,7 @@ export const reorderGroups = (ids: string[]) => invoke("reorder_groups", { export const getGroupMaps = () => invoke("get_group_maps"); export const addGroupMember = (projectId: string, groupId: string, role?: string) => - invoke("add_group_member", { projectId, groupId, role }); + invoke("add_group_member", { projectId, groupId, role }); export const removeGroupMember = (projectId: string, groupId: string) => invoke("remove_group_member", { projectId, groupId }); @@ -145,6 +145,48 @@ export const detectEditors = () => invoke("detect_editors"); export const searchEditorPath = (cmd: string) => invoke("search_editor_path", { cmd }); +export interface McpHealthResult { + ok: boolean; + port: number; + latency_ms?: number; + error?: string; +} + +export const checkMcpHealth = () => invoke("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("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("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("get_mentor_context", { projectId });