diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore index 9de0f169..d20c0fe4 100644 --- a/.codegraph/.gitignore +++ b/.codegraph/.gitignore @@ -1,16 +1,5 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/AGENTS.md b/AGENTS.md index e17f03d8..d660e4f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,14 +70,19 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 发版从 `dev` 汇入 `master` 后,在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + GitHub Release)。**打 tag 前必须在同一批改动里完成三步前置,且随发版改动一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release: 1. **版本号** —— 把 [apps/desktop/package.json](apps/desktop/package.json) 的 `version` 改成目标版本(去 `v` 前缀,预发布带后缀,如 `0.5.0-alpha.1`)。electron-builder 的 `artifactName: code-meeseeks-${version}-...` 直接取此值——不改则安装包文件名缺 `-alpha.N`、与 tag 不符。改完跑一次 `npm install` 同步 lockfile。 -2. **CHANGELOG** —— 把 [CHANGELOG.md](CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - `,在其上另起一个空的 `## [Unreleased]`,并在文件底部补 `[<版本>]: …/compare/…` 链接引用(仿现有行)。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文的「版本变更」区——缺这段则正文回退、无任何变更说明。 +2. **CHANGELOG** —— 把 [CHANGELOG.md](CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - `,在其上另起一个空的 `## [Unreleased]`,并在文件底部补 `[<版本>]: …/compare/…` 链接引用(仿现有行)。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文的「版本变更」区——缺这段则正文回退、无任何变更说明。**若本次是正式版(无 `-` 后缀)、且其内容来自此前的 alpha/预发布**:把被它取代的那些预发布版本段连同底部对应的 `[-alpha.N]:` 链接引用一并**删除**(内容已并入正式版段,不再保留空壳 stub);尚无对应正式版的预发布段保留。 3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(Added / Changed / Fixed)。 tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag(名含 `-`,如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 +**CHANGELOG 撰写风格**(面向用户、求简):① 版本引言 `>` 区的「本版重点」要点用**无序列表**排版,不堆成长句;② Added 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止;③ 重构类任务**前后端合并**为一条总结、不展开实现细节;④ Fixed **不写「怎么修的」机制**,每条一句话只述修复的现象/影响;⑤ 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良。外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 + ## 约定 - **TypeScript strict**;React 19 + electron-vite + Monaco。优先复用现有工具/类型,匹配周边代码风格与注释密度。 +- **包内异常用英语**:`packages/*`(内部库)里 `throw` 的错误信息一律用**英语**、**不做 i18n**(英语为默认/兜底语言,面向开发者排障)。**面向用户展示**的状态文案(如 Agent 的 terminationReason)才走 i18n 资源——二者区分清楚,别把用户文案塞进异常、也别给技术异常做翻译。 +- **后台日志用英语**:`logger.*` / `console.*` 的日志信息一律用**英语**(开发者排障向、不面向用户、不做 i18n)。结构化字段值(路径 / id 等)原样;仅信息文本用英语。 +- **面向用户的错误走错误码**:会跨 IPC 展示给用户的后端错误,统一封装 `AppError`(`code` + 可序列化 `meta`)、以错误码(`E`+两字母领域+四位数字,如 `EAG0001`)承载,本地化由**前端**按码做(i18n `errors.`);后端不拼面向用户的本地化字符串。技术异常 / 日志仍英语(与上两条不冲突——边界是「是否跨 IPC 展示给用户」)。规范见 [docs/arch/12-error-codes.md](docs/arch/12-error-codes.md)。 - **IPC**:main 用 `ipcMain.handle(channel, ...)`,renderer/preload 用泛型 `invoke(channel, req)`,全部由 `packages/shared/src/ipc.ts` 的 `IpcChannels` 类型映射约束。新增通道先在那里加类型。 - **分支策略**:`master` 为发布分支,**禁止直接提交/修改**;所有特性与修复从 `dev` 拉分支开发,汇入 `dev` 验证后再合并到 `master`,发版在 `master` 打 `v*` tag 触发 release。 - **提交信息**:约定式提交、**中文**,带 scope,例:`feat(desktop): …` / `fix(review): …` / `docs(readme): …` / `build(mac): …`。结尾带 `Co-Authored-By` trailer。 diff --git a/CHANGELOG.md b/CHANGELOG.md index df3b8d56..b7b3b751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,294 +5,217 @@ ## [Unreleased] +## [0.6.0-alpha.1] - 2026-06-23 + +> 0.6 的首个开发期预览版。本版重点: +> +> - **`/ask` 复评闭环**:对评审建议发起复评、自动取代 / 关闭原评论 +> - **`/ask` 结构化分段输出**与**完整文件上下文** +> - **Agent 会话中途输入与「计划」面板** +> - **Diff 体验增强**:选区引用提问、按变更范围 / 单 commit 查看、冲突文件标注、删除行评论、滚动条总览标尺 +> - **PR「活动」时间线** +> - 前后端基于领域设计的重大重构(行为不变)与 Agent 编排提速 + +### Added + +- **Agent 评审与对话** + - `/ask` 复评闭环:对 `/review`、`/improve` 的代码评论建议(finding)发起「复评」,按裁决(取代 / 保留 / 撤销)自动取代或关闭原评论;自动评审微流程亦可由 judge 触发复评。 + - `/ask` 结构化分段输出:自由问答按「结论 / 分析解读 / 建议」三段着色呈现,针对代码的建议可定位行号、采纳为行内评论。 + - CLI 模式 `/ask` 取完整文件上下文:本机 CLI 接管时可读取仓库完整文件作答,读取前清空仓库自带 agent 指令文件以防注入污染。 + - 会话「中途输入」与「计划」面板:运行期间再输入消息即时入队并重排后续行动;规划 Agent 维护可视的 todo 计划,随会话持久化、切 PR / 重启自动恢复。 + - run 卡片展示「模型实际交互规模」:呈现提示缓存命中量与模型交互轮次,避免本机 CLI 多轮累加的 token 用量被误读为超限。 +- **Diff 阅览** + - 选中代码引用进提问:选中若干行后作为隐式上下文随提问注入模型、可一键忽略,删除行与未改动行同样可引用。 + - 按「变更范围」查看:可切换查看全部变更或某个 commit 的变更,点击 commit 本地渲染只读 diff、不再跳浏览器。 + - 文件树标注合并冲突文件:有冲突的 PR 对会冲突的文件标琥珀色三角警示图标,无需逐文件试合并即可定位。 + - 给「删除行」新增行内评论 / 草稿:并排视图下 base 侧(删除 / 上下文行)也可 hover「+」创建。 + - 滚动条总览标尺:把增 / 改 / 删与「有评论的行」投影到滚动条旁,拖动即可快速定位。 +- **PR 详情与协作** + - 「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):评论、提交更新、评审决断归并为一条时间线,并可直接发不锚定文件的 summary 评论;GitLab 保持纯评论视图。 + - PR 头部展示 reviewer 头像栈:按评审状态排序展示评审者头像、带决断角标,超出折叠为「+n」下拉。 + - 详情标签页国际化与左右布局:整面板按界面语言出文案,改为左描述 / 右时间线 + 评审者列表、窄宽响应式堆叠。 +- 连接 / LLM 配置模态退出拦截:有未提交改动时关闭弹确认框,避免误丢未保存内容。 + +### Changed + +- **前后端基于领域设计的重大重构**(可维护性,行为不变):按领域边界重组前后端代码,划清模块职责与依赖方向。 + - 前端:组件按 `common`(基础 UI)/ `layout`(应用骨架)/ `features`(业务领域)分层,业务逻辑下沉所属领域,超大组件(ChatPane / SettingsModal / DiffView 等)拆为「容器 + 领域组件 + hooks」。 + - 后端:抽出 IPC 服务层、按领域分组 Agent 服务、解耦运行队列;Agent 引擎抽出可插拔「步骤」抽象统一记步与用量累计,编排提示词外置为资源文件。 + - 对外接口、界面与交互行为均不变。 +- **Agent 编排响应提速**(对用户行为不变):条件追问并行派发、追问判读瘦身为轻量路由、编排链路统一低推理 + 判读输出封顶,并把全局稳定系统前缀接入 Anthropic 1h 提示缓存,整体延迟与成本下降。 +- 复评 `/ask` 取代 / 撤销改为静默自动关闭原 finding,「取代」裁决把建议提升为可采纳的代码反馈卡,前端仅只读展示关闭态与「查看复评」导航。 +- agent「评审总结」聚焦 PR 整体结论:只吃每条追问的结论而非完整答案明细,输出 PR 级整体结论、不复制明细。 +- PR 提交列表 / 活动时间线按 first-parent 过滤合入的他人提交,只保留本 PR 自产提交;镜像未就位时回退不丢信息。 +- 评审 / Diff 界面交互打磨(一批小优化):评审总结卡与 finding 卡统一样式行距、可折叠卡整行标题即展开并带过渡动画、点击复评引用徽标定位并高亮原卡、危险按钮统一为高饱和红、设置模态复用首启向导左右布局,及移除「已达并发上限」横幅、隐藏 `/review`「评估工作量」段等。 + +### Fixed + +- 源分支 merge 目标分支后,变更页 diff 混入目标分支的已有改动。(#107,感谢 @csj2000) +- `/ask` 的结构化分段 / 引用上下文 / 复评裁决指令此前对模型不生效。 +- 复评「取代」裁决的改进建议改为可直接发布的替代评论,不再是关于评论的元讨论。 +- 失败 / 取消的任务不再产出无意义的 finding 卡。 +- CLI 模式 `/ask` 在仓库自带 agent 指令文件被版本管理时整体失败。 +- 本地镜像缺 PR head sha(源分支被删 / 强推)导致 diff / 评审失败且不自愈。 +- 消除 PR 切换 / 刷新 / 标签页切换时的多处渲染抖动与闪烁。 +- 消除 Monaco 控制台 `Missing requestHandler` 噪音报错。 +- 评审总结偶发被截断 / 回落「无法解析建议」。 +- 拉取变更文件列表偶发失败(`ENOENT … diff-base.json`)。 +- 合并已合并 / 已关闭的 PR 时报错不友好。 +- 补齐 PR 评审状态 chip、Agent 步骤行等写死文案的国际化。 +- 设置页手动「检查更新」结果即时同步到状态栏。 +- PR 详情 / 评论页正文限宽居中、reviewers 列表排序稳定。 + ## [0.5.0] - 2026-06-17 -> 首个 0.5 正式版。本版重点:在 PR 评审中引入可委派的**高阶 Agent(会话 Agent 化 + AutoPilot 后台预评审)**, -> 并打磨无边框窗口、重型组件加载抖动、评论嵌套展示等体验。开发期 0.5.0-alpha.1 的变更已并入本版。 +> 首个 0.5 正式版。本版重点: +> +> - 可委派的**高阶 Agent**(会话 Agent 化 + AutoPilot 后台预评审) +> - **无边框窗口 + 自绘标题栏** +> - 重型组件加载抖动、评论嵌套展示等体验打磨 ### Added -- **高阶 Agent(会话 Agent 化 + AutoPilot 预评审)**:在 PR 评审中引入可委派的智能体能力,随 LLM - 配置自动可用、无需单独的启用开关。 - - **一键自动评审**:聊天框命令区右侧新增自动评审按钮(✦ 图标),对当前 PR 跑「描述 → 评审 → - (仅严重问题)条件追问 → 收尾总结」微流程,给出非约束性建议(建议通过 / 建议修改 / 建议人工 - 复核);过程步骤按自然时间顺序内联展示,结尾汇总为「评审总结」卡片。 - - **对话即委派**:聊天框直接输入自然语言,自由规划 Agent 按需调用只读工具(描述 / 评审 / 追问) - 完成请求——与 PR 内容相关但无明确工具指向时默认走追问兜底,与 PR 无关的请求则礼貌拒绝;运行中 - 可随时停止。 - - **AutoPilot 后台预评审**:状态栏开关启用后,对满足最小间隔的新 PR 在后台自动预评审,建议倾向 - 落入 PR 列表徽标,收尾总结同步落入该 PR 会话(与手动评审一致,可在聊天里看到「评审总结」卡片); - 写操作经逐项授权 + 红线硬校验把关(默认全拒,仅开放只读工具)。准入从严:仅对 - **「待我评审」分类下、「待处理」状态**的 PR 触发;会话中一旦已有 `/describe` 或 `/review` 产出 - (手动或自动)即判定已评审、不再自动触发,避免重复评审。启用开关时(关 → 开)立即触发一次 poll - 并按上述规则评估、按需开评审,不必等下个轮询周期。评估节奏对齐轮询——每个 poller tick(间隔 = - `poller.interval_seconds`)评估一遍,不再单设独立的最小间隔配置(准入门控 + 台账去重已防重复 / 打爆 - LLM)。PR 在 poll 中被移除 / purge 时,其上仍在执行的 agent 操作(编排 + 派发的工具 run)一律即时终止, - 不为已消失的 PR 空耗。多个待评审 PR 在一轮内并行编排,尽量填满工具的并发队列、不逐 PR 串行空等。 - - **评审状态可视化**:PR 列表项在同一位置展示——有在跑的 agent 任务时蓝色「执行中」旋转指示(复用 - 运行卡片同款 .spinner、中心对称、无 chip 外框),否则展示评审建议 ★(手动 / AutoPilot 一视同仁, - approve 绿 / needs_work 琥珀 / manual_review 蓝,SVG 居中);AutoPilot 触发的评审在其**首个步骤行** - 打机器人标记,与手动触发区分。排队位次取全局队列位序(跨 PR 共享队列,不再每 PR 都显示「第 1 位」)。 - - **并行多问**:规划 Agent 可在一轮内并行派发多个 `/ask`(`tools` 元素支持 `{tool, question}` 形式), - 经运行队列并发执行,而非逐个串行。 - - **评审步骤 token 用量可见**:编排的每个推理步(判读 / 总结 / 自由规划)在步骤行右侧分步展示本步 - LLM token 用量(↑输入 / ↓输出,**不累计**);工具调用(描述 / 评审 / 追问)的开销仍由各自运行卡片承载。 - - **Agent 上下文目录**:以 SOUL / AGENTS / MEMORY / USER 与 rules/ 规则子目录构成 Agent 的人格与 - 知识来源;未配置自定义目录时默认落 `~/.code-meeseeks/agent`,首次启动幂等补齐模版,开箱即用。 -- 设置页「运行环境」新增「关于 & 反馈」入口:GitHub 仓库(Star)/ 提交 Issue / Releases 三个外链 - (各带专属图标,点击经系统浏览器打开),低频社区入口集中于「关于」区、不进状态栏。 -- **无边框窗口 + 自绘标题栏**(VS Code 风):主窗口去掉系统原生标题栏,渲染层自绘 36px 标题栏, - 深色主题从顶贯通到底。窗控按钮交由系统绘制以保留原生行为——macOS 保留红绿灯(下移到标题栏内)、 - Windows/Linux 用 `titleBarOverlay` 在右上画最小化/最大化/关闭。标题栏展示品牌名与当前 PR 标题, - Windows/Linux 开头另显应用图标(macOS 因红绿灯占位不显)。 -- PR 列表项「执行中」标记覆盖 Agent **纯思考阶段**(无活跃工具运行时,含后台 AutoPilot),不再只在工具运行时显示。 +- **高阶 Agent(会话 Agent 化 + AutoPilot 预评审)**:在 PR 评审中引入可委派的智能体,随 LLM 配置自动可用、无需单独启用开关。 + - 一键自动评审:对当前 PR 跑「描述 → 评审 →(仅严重问题)追问 → 总结」微流程,给出非约束性建议(建议通过 / 修改 / 人工复核)并汇总为「评审总结」卡片。 + - 对话即委派:聊天框输入自然语言,规划 Agent 按需调用只读工具完成请求,与 PR 无关的请求礼貌拒绝、运行中可随时停止。 + - AutoPilot 后台预评审:对「待我评审」且「待处理」的新 PR 自动预评审,建议落入列表徽标、总结落入会话;写操作经逐项授权 + 红线校验把关(默认仅开放只读工具)。 + - 评审状态可视化:PR 列表项展示蓝色「执行中」旋转指示或评审建议 ★(覆盖纯思考阶段),AutoPilot 触发的评审打机器人标记。 + - 并行多问:规划 Agent 可在一轮内并行派发多个 `/ask`。 + - 评审步骤 token 用量可见:每个推理步右侧分步展示本步 token 用量(不累计)。 + - Agent 上下文目录:以 SOUL / AGENTS / MEMORY / USER 与 rules/ 构成 Agent 的人格与知识来源,默认落 `~/.code-meeseeks/agent`、首启幂等补齐模版。 +- **无边框窗口 + 自绘标题栏**(VS Code 风):去掉系统原生标题栏、渲染层自绘 36px 标题栏、深色主题贯通到底,窗控按钮交由系统绘制保留原生行为,标题栏展示品牌名与当前 PR 标题。 +- 设置页新增「关于 & 反馈」入口:GitHub 仓库 / 提交 Issue / Releases 三个外链。 ### Changed -- **移除独立 `ollama` provider**,统一经 `openai-compatible` 接入本地 Ollama(Base URL 填 - `http://localhost:11434/v1`):Ollama 自带 OpenAI 兼容端点,走此路径更标准稳健。旧 `ollama` 配置 - 加载时**自动迁移**为 `openai-compatible` 并补足 `/v1`,存量无感升级。 -- `openai-compatible` 经实测标记为**已验证**。 -- **重型组件加载抖动收敛**:切换 PR / 文件时,diff(Monaco)、聊天会话内容等重型区域在 - 异步初始化完成前统一盖一层居中 loading,就绪后一次性 reveal,消除「空白 → 内容弹出 → 折叠跳一下」 - 的多段重排。loading 延迟显示(>150ms 才出现)——本地缓存命中的快切换零闪烁,仅真慢场景才落到 - loading。Monaco 区域特别处理:从挂载第一帧即盖遮罩、并等折叠(hideUnchangedRegions)布局 paint - 稳定后才揭开,遮罩底色与编辑器一致、揭开无缝。 -- **describe「文件变更」分类默认折叠**:walkthrough 各文件分类(功能增强 / 配置变更 …)默认折叠收起, - 点分类标题按需展开,避免 describe 输出过长(仅作用于 walkthrough,不影响其它折叠区)。 -- **评论嵌套展示统一**(评论 tab + 行内评论):回复满 5 层后拉平为同一缩进层级、纵向排列并加横向分割线, - 避免无限右移;嵌套回复改走「左竖线缩进」的扁平样式(不再层层卡片「盒中盒」),两处视觉一致。 -- 评审建议星标由五角星改为 AI 常见的四角 sparkle ✦。 -- /ask 在问题末尾追加语言要求,改善按界面语言(中 / 日 / 德)作答的遵循度——此前自由问答常被大量英文 diff 盖过而用英文作答。 -- 统一 PR 列表状态 chip 带高,消除「星标」与「星标 + 计数」等不同行的高度漂移。 -- 评审总结不再硬截断:`summary_max_chars` 仅作提示词里的参考性软约束引导 LLM 收敛篇幅,AI 已生成的总结完整保留,不再被切在词中间(如「参数…」)。 +- **重型组件加载抖动收敛**:切换 PR / 文件时 diff(Monaco)、会话内容等重型区域统一盖延迟 loading、就绪后一次性 reveal,缓存命中的快切换零闪。 +- 移除独立 `ollama` provider,统一经 `openai-compatible` 接入本地 Ollama(自带兼容端点、更标准),旧配置自动迁移;`openai-compatible` 标记为已验证。 +- 评论嵌套展示统一(评论 tab + 行内):回复满 5 层拉平为同层级、嵌套改「左竖线缩进」扁平样式。 +- describe「文件变更」分类默认折叠,避免输出过长。 +- 评审总结不再硬截断:`summary_max_chars` 仅作软约束,已生成内容完整保留。 +- 一批 UI 细节:评审建议星标改为四角 sparkle ✦、统一 PR 列表状态 chip 带高消除行高漂移、`/ask` 问题末尾追加语言要求改善按界面语言作答。 ### Fixed -- **修复 PR diff 基准随目标分支漂移导致的「修改被撤回」误判**:此前文件内容(Monaco 左栏)按目标 - 分支当前 tip(`targetRef.sha`)读取,目标分支被别的 PR 合入而前移后,编辑器实际成了两点对比, - 别的 PR 的改动会以倒挂 / 撤回形式串进当前 PR 的 diff(变更文件列表用三点 diff 本不受影响,但内容 - 与之不一致)。改为首次为 PR 算出 `merge-base(target, head)` 并固化到 `prs//diff-base.json`, - 之后变更文件列表 / 文件内容 / 提交计数 / blame 改动行 / pr-agent 评审一律以它为 base:编辑器即真 - 三点、对目标分支前移稳定,行锚点(评论 / finding)也有了固定参照。源分支被 rebase(固化 base 不再是 - head 祖先)时自动重算;正常 push 不失效。固化值为本地派生缓存、独立于平台元数据,poller 重写 - meta.json 不触碰;历史 PR 无需迁移,首次访问 diff 时按需回填(算不出则退回旧行为且不固化)。 -- 修复 Windows 控制台中文日志仍显示为乱码:① dev 下 electron-vite 把 main 的 stdout 接成管道 - (`isTTY=false`)原会跳过转码,UTF-8 字节被 CJK 控制台按 GBK/SJIS 渲染——改为 `pretty` 模式不卡 - `isTTY`(与上色路径一致);② 启动期探测真实活动代码页(`chcp`)替代按 locale 猜测:UTF-8 控制台 - (65001)直出 UTF-8,CJK 代码页(cp936/cp932/cp949/cp950)转码到对应页,避免用户已 `chcp 65001` - 切到 UTF-8 时反而把正确输出转乱。 -- 修复 finding 锚点解析在文件路径含方括号(如 `a/[m-123]/x.ts`)时出错:marker `[file: …, lines: …]` - 的路径捕获原排除了 `]`,遇到路径里的 `]` 即误截,导致 marker 抽不出跳转锚点、且原样泄漏到 - finding 正文。改为带 lines 时以 `, lines:` 后缀界定路径(允许路径含 `[]`)。 -- 清空某 PR 执行历史时一并清掉其 PR 列表 AI 评审建议 ★ 徽标,不再残留陈旧评审状态。 -- 自动评审(手动 / AutoPilot)完成后,PR 列表的评审建议 ★ 现即时更新,不必等下个轮询周期才体现。 -- PR「提交」数角标排除「源分支把目标分支合入自己」带进来的提交与 merge 提交,与「提交」列表口径一致(此前会多计)。 -- 补 walkthrough 文件分类标题「Miscellaneous」「Formatting」「Dependencies」的中 / 日 / 德译文(此前非英文界面下仍显示英文)。 -- Anthropic provider 配置的 base_url(自建 / 中转端点)此前未透传给底层 litellm → 请求仍打到官方 `api.anthropic.com`;现经 `ANTHROPIC_API_BASE` 正确透传(填根域名即可,litellm 自动补 `/v1/messages`)。(#65,感谢 @dnvyrn) -- 本地仓库镜像 clone/fetch 中途被打断后留下残缺镜像(缺 origin remote),导致后续拉取变更文件一直 fatal(`'origin' does not appear to be a git repository`)、点「重试」也卡在同一坏镜像:现自动识别不健康 / 损坏镜像并删库重建,可自愈。 -- 消除评论页 poll / 刷新触发的渲染抖动:pr 按 localId 冻结后下传、评论内容结构相等就跳过重渲染、内嵌 Monaco 按锚点值 memo——定位信息没变时不再重渲染重排。 - -## [0.5.0-alpha.1] - 2026-06-17 - -> 开发期预览版。其全部变更内容已并入正式版 **[0.5.0](#050---2026-06-17)**,此处不再展开。 +- 修复 PR diff 基准随目标分支漂移导致的「修改被撤回」误判。 +- 修复 Windows 控制台中文日志乱码。 +- 修复 finding 锚点解析在文件路径含方括号(如 `a/[m-123]/x.ts`)时出错。 +- 修复 Anthropic provider 的自建 / 中转 base_url 此前不生效。(#65,感谢 @dnvyrn) +- 本地镜像 clone/fetch 被打断留下的残缺镜像现可自动重建自愈。 +- 清空 PR 执行历史时一并清掉列表评审建议 ★,自动评审完成后 ★ 即时更新。 +- PR「提交」数角标排除源分支合入目标分支带来的提交与 merge 提交。 +- 补 walkthrough 文件分类标题(Miscellaneous / Formatting / Dependencies)的中 / 日 / 德译文。 +- 消除评论页 poll / 刷新触发的渲染抖动。 ## [0.4.0] - 2026-06-14 -> 第四个正式版(仍属 0.x · 早期预览)。本版重点:**接入 GitLab**(gitlab.com + Self-Managed, -> CE / EE),评审交互与渲染打磨(拒绝折叠收起、代码建议草稿锚点对齐、评论内嵌附件图片、GitHub / -> GitLab 评论编辑删除),**连接 Base URL 放宽**,以及 **Windows 升级安装健壮性**(per-machine 提权 + -> 绕过旧卸载器)。开发期 0.4.0-alpha.1 的变更已并入本版。 +> 第四个正式版(仍属 0.x · 早期预览)。本版重点: > -> ⚠️ **Windows 安装说明**:本版为 **per-machine 安装**(所有用户 / Program Files),安装器双击即弹 -> UAC 提权运行;安装后的应用以普通权限启动。从旧版升级会自动清理旧安装,无需手动卸载。 +> - **接入 GitLab**(gitlab.com + Self-Managed,CE / EE) +> - 评审交互与渲染打磨(拒绝折叠、草稿锚点对齐、评论内嵌附件图片、GitHub / GitLab 评论编辑删除) +> - **连接 Base URL 放宽** +> - **Windows 升级安装健壮性**(per-machine 提权 + 绕过旧卸载器) +> +> ⚠️ **Windows 安装说明**:本版为 per-machine 安装(所有用户 / Program Files),安装器双击即弹 UAC 提权运行;从旧版升级会自动清理旧安装,无需手动卸载。 ### Added -- **GitLab 接入**(gitlab.com + Self-Managed,CE / EE,REST API v4):新增 `@meebox/platform-gitlab` - 适配器——MR 发现(`reviewer_username` 待我评审,跨项目)、diff 评论读 / 发 / 改 / 删 / 回复 - (discussions + notes 归一)、合并、clone(PAT / SSH)、头像 / 内嵌附件代理。设置页与首启向导可 - 新增 GitLab 连接(Base URL 可留空默认 gitlab.com)。 - - **CE / EE 审批降级**:MR approve/unapprove API 自 13.9 起为 Premium/Ultimate,且 GitLab 审批二元 - (无「需修改」)。经 `/metadata` 探测 edition,能力位据此降级——EE:通过 / 撤销;CE:无 API 审批、 - UI 灰显。可合并状态走 `detailed_merge_status`(full 保真)。 - - 嵌套 group 路径、N+1 取详情(diff_refs / 审批)、行内评论按 `position` 三 sha 锚定。 +- **GitLab 接入**(gitlab.com + Self-Managed,CE / EE,REST API v4):MR 发现、diff 评论读 / 发 / 改 / 删 / 回复、合并、clone(PAT / SSH)、头像 / 附件代理;设置页与首启向导可新增 GitLab 连接(Base URL 可留空默认 gitlab.com)。 + - CE / EE 审批降级:经 `/metadata` 探测 edition——EE 支持通过 / 撤销,CE 无 API 审批、UI 灰显(GitLab 审批二元、无「需修改」)。 ### Changed -- 拒绝代码反馈 / 改进建议后,卡片自动折叠收起并置灰:左色条转中性灰、类别 chip 置灰,正文与 - 代码对比收起,仅保留头部与锚点行(含撤销入口);头部 chevron 图标可临时展开回看。降低已决断 - 项的视觉占用。 -- 危险按钮(「清空」「删除」等确认操作)实底由偏浅的鲑红改为饱和红 `#c72e0f`,提高警示力。 -- Windows 安装页不再强制展开文件日志列表:electron-builder 整包解包(`Nsis7z::Extract` + - `CopyFiles /SILENT`)不产生逐文件日志,展开只会显示空白框、反而像卡住,改为仅保留进度条; - 卸载页仍展开(逐文件删除有真实进度)。 -- **连接 Base URL 放宽**:GitHub Enterprise / GitLab Self-Managed 可直接填实例地址(如 - `https://ghe.example.com`),`/api/v3`、`/api/v4` 自动补全;github.com / gitlab.com 留空即用默认。 - 免去记忆 API 路径(此前 GHE 漏填 `/api/v3` 会失败)。 -- 设置页连接 / LLM 预设卡片显示对应**品牌类型图标**(代码平台 / LLM provider,与首启向导同源), - 一眼区分类型、避免误配。 -- **本地 CLI 类 LLM provider 标注「实验性」**:卡片琥珀徽标 + 配置注释(🧪)+ 文档说明,提示其 - 依赖上游 CLI(claude / codex 等)、行为可能随上游版本变更,稳定性与持续可用性不作保证。 +- **连接 Base URL 放宽**:GitHub Enterprise / GitLab Self-Managed 可直接填实例地址(如 `https://ghe.example.com`),`/api/v3`、`/api/v4` 自动补全;github.com / gitlab.com 留空即用默认。 +- 拒绝代码反馈 / 改进建议后卡片自动折叠置灰、仅保留头部与锚点行(含撤销入口),降低已决断项视觉占用。 +- 本地 CLI 类 LLM provider 标注「实验性」:提示其依赖上游 CLI(claude / codex 等)、稳定性不作保证。 +- 设置页连接 / LLM 预设卡片显示对应品牌类型图标避免误配;危险按钮实底改为饱和红提高警示力;Windows 安装页不再展开空白的文件日志列表、仅留进度条。 ### Fixed -- Bitbucket 评论内嵌附件图片不渲染:`rehype-sanitize` 的协议白名单(`src` / `href` 仅 - http/https)在 `urlTransform` 之前即剥掉 `attachment:` 内部协议,使 img/a 收不到 src/href、 - 图片代理永不触发(属随 sanitize 链引入的回归)。schema 放行 `attachment` 协议;并让附件拉取 - 失败不再静默吞错(记 status / 重定向 / 最终 URL / content-type)。 -- 代码建议草稿区的锚定行与最终发布落点不一致:草稿预览按 `startLine` 渲染、发布却落 - `endLine`。统一以发布落点为准,草稿预览行与跳转高亮行改用 `endLine`,实现「预览位置 = 远端 - 评论落点」(评论统一落在 finding 范围末行)。 -- **GitHub 无法编辑 / 删除自己的评论**:评论可编辑 / 删除判定此前一律要求 `version`(仅 Bitbucket - 的乐观锁语义),而 GitHub / GitLab 评论无此字段 → 编辑 / 删除入口从不出现。改用「无需并发令牌」 - 哨兵统一通过判定,恢复 GitHub / GitLab 评论的编辑与删除;「带 reply 不可删」收敛为 Bitbucket 专属。 -- 评论内嵌图片代理失败时,降级为指向 PR 网页的「浏览器打开」链接(在系统浏览器带 session 渲染评论 - 与图片),不再显示破图标;并修正相对图片路径在降级时误跳 localhost。 -- **Windows 升级安装卡死 /「无法关闭」**:① 改为 per-machine 提权安装(清单 requireAdministrator, - 双击即弹 UAC、提权运行),取代 perMachine:false 在已存在 per-machine 安装时「按需提权失败 → 静默 - 退出 → 双击打不开」的半吊子路径;② 升级时绕过 electron-builder 旧卸载器——在 customInit(早于 - uninstallOldVersion 执行)清掉旧版卸载注册表项使其读空值直接 no-op、改由安装器自行强删旧目录, - 规避旧卸载器原位 `_?=` 模式下「数万文件原子 rename、瞬时占用即整批回滚 → 重试 5 次后『无法关闭』」 - 的死结。 - -## [0.4.0-alpha.1] - 2026-06-14 - -> 开发期预览版。其全部变更内容已并入正式版 **[0.4.0](#040---2026-06-14)**,此处不再展开。 +- 修复 GitHub / GitLab 无法编辑 / 删除自己的评论。 +- 修复 Bitbucket 评论内嵌附件图片不渲染。 +- 修复代码建议草稿区的锚定行与最终发布落点不一致。 +- 评论内嵌图片代理失败时降级为「浏览器打开」链接,不再显示破图标。 +- 修复 Windows 升级安装卡死 /「无法关闭」。 ## [0.3.1] - 2026-06-11 ### Fixed -- **macOS 分发版「本地 CLI」provider(claude / codex)失效**(Finder/Dock 启动):macOS GUI 应用 - 只继承 launchd 的最小 PATH(`/usr/bin:/bin:/usr/sbin:/sbin`),读不到 shell 配置,故找不到装在 - `~/.local/bin` / homebrew 等目录的 CLI,评审报错(`litellm ... LLM Provider NOT provided` 或 - "找不到命令")。启动期把常见 CLI 安装目录(`~/.local/bin` / `/usr/local/bin` / - `/opt/homebrew/bin` 等)前置进 `PATH`,使嵌入式 python 及其 CLI 子进程都能定位命令。仅 macOS - 受影响;终端启动(dev)与 Windows 不受影响。(#21) +- 修复 macOS 分发版「本地 CLI」provider(claude / codex)经 Finder / Dock 启动时因 PATH 不全而失效。(#21) ## [0.3.0] - 2026-06-11 -> 第三个正式版(仍属 0.x · 早期预览)。本版重点:**界面国际化(四语 + 即时切换)**、Mermaid 架构图 -> 渲染、版本更新检测、`/improve` 与 `/describe` 思路建议段等 pr-agent 能力扩展,并修复首启同步、 -> 子进程树清理与安装 / 升级健壮性。开发期 0.3.0-alpha.1 的变更已并入本版。 - -> ⚠️ **Windows 用户升级注意**:若已安装**早期版本**(含 `0.3.0-alpha.1` 及更早),升级到本版前请 -> **先手动卸载旧版**(设置 → 应用 → Code Meeseeks → 卸载,或安装目录下的 `Uninstall Code Meeseeks.exe`), -> 完成后再运行新安装器;否则覆盖安装可能长时间卡住或弹出「Code Meeseeks 无法关闭」。 -> 原因:早期版本运行时会在安装目录写入上万个 Python 字节码(`.pyc`)缓存文件,使覆盖升级时「卸载旧版」 -> 一步需逐个删除海量小文件、极慢甚至卡死。本版起运行时不再写入这些缓存,**之后的升级可正常覆盖、无需手动卸载**。 +> 第三个正式版(仍属 0.x · 早期预览)。本版重点: +> +> - **界面国际化**(四语 + 即时切换) +> - **Mermaid 架构图渲染** +> - **版本更新检测** +> - `/improve` 与 `/describe` 思路建议段等 pr-agent 能力扩展 +> - 修复首启同步、子进程树清理与安装 / 升级健壮性 +> +> ⚠️ **Windows 用户升级注意**:若已安装早期版本(含 `0.3.0-alpha.1` 及更早),升级前请**先手动卸载旧版**再运行新安装器,否则覆盖安装可能卡住或弹出「无法关闭」。本版起不再写入大量 `.pyc` 缓存文件,之后的升级可正常覆盖、无需手动卸载。 ### Added -- **多语言界面(i18n)**:接入 **react-i18next**,全部 GUI 文本与主进程面向用户文案(目录对话框 / - 错误提示)从硬编码抽取为 locale 资源(按组件命名空间组织、递归字典序维护),覆盖 **简体中文 / - English / 日本語 / Deutsch** 四语;pr-agent 输出模板的渲染期翻译同步语言感知(中文 / 日语 / 德语 - 查表、英语 passthrough)。 - - **语言选择**:设置页与首启向导提供下拉选择(各语言以自身名称展示、不随 UI 翻译),**即时生效**—— - 写盘 + 渲染层实时切换,AI 回复语言随之(下次运行起)。 - - **语言解析**:`config.language` 为空时按**操作系统偏好语言**自动匹配,非空则按显式选择。默认 / - 兜底语言为 **en-US**(缺译文回退英文而非中文)。 - - **按需懒加载**:默认语言(en-US)静态进入口(首帧不闪),其余语言由 Vite 拆成独立 chunk、切换时 - 才拉取,不进入口包。`ja-JP` / `de-DE` 为机器初稿,发布前建议人工校对。 -- **Mermaid 图渲染**:markdown 里的 `mermaid` 代码块(Qodo `/describe` 常生成的架构图)渲染为图形, - 覆盖 PR 描述 / 评论 / chat 评审输出。mermaid 懒加载(独立 chunk,仅出现图表时才拉取,不进入口包); - 深色主题、`securityLevel: strict`,渲染失败回退原始代码块。 -- **版本更新检测**:启动时(及设置页「检查更新」)查 GitHub Releases 最新稳定版与当前版本比对, - 有新版在状态栏提示并可点击前往下载(仅检测 + 提示,不自动下载 / 安装)。检测走配置的出站代理 - (内网友好),可经 `update.check_enabled` 关闭。 -- **/describe 架构图**:嵌入式 pr-agent 统一启用 GFM(shim 让本地 provider 支持 gfm_markdown), - 使社区版 `/describe` 的 `enable_pr_diagram`(默认开)按实际改动**选择性输出 mermaid 架构图**, - 配合 Mermaid 渲染直接成图;`/review` 等同步走 GFM 富 markdown,输出解析(parse-output)相应 - 兼容 GFM 的 `` / `
` / `` finding 形态。 -- **describe 排版优化**:架构图、文件变更各自独立成段,配中文色块标题(「架构图」/「文件变更」); - 文件变更保留多级分类、每个分类独立成可收起/展开的折叠块(去掉无意义的 +1/-1 统计); - mermaid 图点击进入模态预览,支持滚轮缩放、拖拽平移与「适应窗口」,预览区为固定纯色背景。 -- **清空执行历史**:chat 面板标题栏新增垃圾桶按钮,清空**当前 PR**的 PR Agent 执行历史记录(仅该 PR)。 -- **启用 `/improve` 指令**:逐行代码改进建议(带 1-10 重要度评分)。依托 shim 的 GFM 支持走 - 「汇总建议」路径(committable/inline 模式在本地 provider 下不可用,已显式关死兜底);输出落 - 独立 `improve.md` 与 `/review` 分流(经 `local.review_path` 原生配置);关闭 persistent_comment - 避免本地 provider 翻历史评论刷无意义 traceback。 -- **/describe 思路建议段**:shim 往 describe prompt 注入 `assessment` 字段,让社区版 `/describe` - 额外产出「思路建议」段——2-4 个替代实现方案(各自折叠)+ 倾向性推荐,对齐 Qodo Merge 的 - High-Level Assessment(社区版原生无此字段)。pr-agent 通用渲染成段、parse-output 映射 sectionKey, - 英文结构串经渲染期翻译表中文化,chip 配主蓝(信息性)色。 +- **多语言界面(i18n)**:接入 react-i18next,全部 GUI 文本与主进程面向用户文案覆盖**简体中文 / English / 日本語 / Deutsch** 四语;pr-agent 输出模板渲染期翻译同步语言感知。 + - 语言选择:设置页与首启向导下拉选择、即时生效,AI 回复语言随之(下次运行起)。 + - 语言解析:`config.language` 为空时按操作系统偏好语言匹配,默认 / 兜底为 en-US。 + - 按需懒加载:默认语言静态进入口,其余语言切换时才拉取(`ja-JP` / `de-DE` 为机器初稿)。 +- **Mermaid 架构图渲染**:markdown 中的 `mermaid` 代码块渲染为图形,覆盖 PR 描述 / 评论 / chat 评审输出,点击进入模态预览(缩放 / 平移 / 适应窗口),渲染失败回退原始代码块。 +- **版本更新检测**:启动时及设置页查 GitHub Releases 最新稳定版比对,有新版在状态栏提示并可点击前往下载(仅检测、不自动安装),走配置的出站代理、可关闭。 +- **启用 `/improve` 指令**:逐行代码改进建议(带 1-10 重要度评分),输出落独立 `improve.md` 与 `/review` 分流。 +- **/describe 架构图与思路建议段**:统一启用 GFM 使社区版 `/describe` 选择性输出 mermaid 架构图;并注入「思路建议」段——2-4 个替代实现方案(各自折叠)+ 倾向性推荐。 +- describe 排版优化:架构图、文件变更各自独立成段配色块标题,文件变更按分类折叠。 +- **清空执行历史**:chat 面板标题栏新增垃圾桶按钮,清空当前 PR 的执行历史。 ### Fixed -- 修复活动连接无缓存身份时首启「看似未触发远端同步」:改为先经 ping 确认身份、再立即同步一次 - (有缓存身份仍立即同步),不再用 me=null 跑半成品首轮。 -- 修复取消 / 超时 / 退出时只终止 pr-agent 的 python 主进程、其 litellm 等孙进程变孤儿(Windows - `child.kill` 不级联):改为进程树级终止(win32 `taskkill /T /F`),避免孤儿进程锁住安装目录。 -- **安装 / 升级健壮性**:嵌入式 python 运行期不再写 `.pyc`(`PYTHONDONTWRITEBYTECODE`)、运行时瘦身 - (删 tests / `__pycache__` / 类型存根等)+ 构建期端到端冒烟(防过度裁剪);NSIS 安装器强杀残留进程 - 不弹阻塞框 + 展开文件处理进度。减少安装目录小文件数,缓解升级时卸载缓慢 / 卡死。**已装早期版本仍需 - 先手动卸载再升级**(见上方注意事项)。 - -## [0.3.0-alpha.1] - 2026-06-11 - -> 开发期预览版。其全部变更内容已并入正式版 **[0.3.0](#030---2026-06-11)**,此处不再展开。 +- **安装 / 升级健壮性**:减少安装目录小文件数,缓解升级卸载缓慢 / 卡死(已装早期版本仍需先手动卸载)。 +- 修复取消 / 超时 / 退出时 litellm 等孙进程变孤儿。 +- 修复活动连接无缓存身份时首启「看似未触发远端同步」。 ## [0.2.0] - 2026-06-09 -> 第二个正式版(仍属 0.x · 早期预览)。本版重点:**接入 GitHub**(github.com + GitHub Enterprise Server) -> 与多平台适配抽象、**评审任务并发执行**、**启动显著提速**,并**移除 Docker 运行策略**收敛到内嵌运行时。 -> 开发期 0.2.0-alpha.1 / alpha.2 的变更已并入本版。 +> 第二个正式版(仍属 0.x · 早期预览)。本版重点: +> +> - **接入 GitHub**(github.com + GitHub Enterprise Server)与多平台适配抽象 +> - **评审任务并发执行** +> - **启动显著提速** +> - **移除 Docker 运行策略**,收敛到内嵌运行时 ### Added -- **GitHub 适配**(github.com + GitHub Enterprise Server,REST API v3):PR 发现、diff 评论读写、 - 行内评论、审批(通过 / 需修改 / 撤销)、合并;设置页与首启向导可新增 GitHub 连接,连接配置中置顶。 - 审批按平台能力降级:不支持的决断隐藏,自己作者的 PR 审批按钮灰显。GitHub Base URL 可选,留空默认 - `api.github.com`。 -- **多平台适配抽象基线**:`PlatformAdapter` 能力描述符(`capabilities()`)、`PrDiffRefs`、`PrComment` - 线程字段(kind / threadId / nativeId);UI 据能力位 显 / 隐 / 灰,不在调用处写 `if (platform === ...)`。 -- **PR 发现分类**:GitHub 对齐仪表盘四类(待我评审 / 我创建 / 指派我 / 提及我);Bitbucket 增 - 「待我评审 / 我创建」两类。能力驱动 + 分类结果本地缓存,渲染层按标签本地过滤。 -- **单活动连接模型**:PR 列表与状态栏只反映当前活动连接;切换活动连接后归档旧连接的 PR。 -- **评审任务并发执行**:队列从单并发改为可配置并发(每个 run 独立 worktree + 独立子进程,并发安全), - 多个 PR 的 review 可并行、互不阻塞。并发数由 `pr_agent.max_concurrency` 控制(1~8,默认 2,仅 - config.yaml 手改)。同一 PR 同一工具运行 / 排队中禁止重复触发(`/ask` 不限)。 -- **本地 CLI 模型 provider**(`cli`):不直连模型 API,把评审请求转交本机已安装并授权的命令行工具 - (Claude Code / Codex CLI)执行评审;其凭据与计费由该 CLI 自理。 +- **GitHub 适配**(github.com + GitHub Enterprise Server,REST API v3):PR 发现、diff 评论读写、行内评论、审批(通过 / 需修改 / 撤销)、合并;审批按平台能力降级,自己作者的 PR 审批按钮灰显。 +- **多平台适配抽象基线**:`PlatformAdapter` 能力描述符 + 评论线程字段,UI 据能力位显 / 隐 / 灰,不在调用处写平台判断。 +- **PR 发现分类**:GitHub 对齐仪表盘四类(待我评审 / 我创建 / 指派我 / 提及我),Bitbucket 增两类;结果本地缓存、按标签本地过滤。 +- **评审任务并发执行**:队列改为可配置并发(每个 run 独立 worktree + 子进程),多个 PR 评审可并行,并发数由 `pr_agent.max_concurrency` 控制(1~8,默认 2)。 +- **本地 CLI 模型 provider**(`cli`):把评审请求转交本机已安装并授权的命令行工具(Claude Code / Codex CLI),凭据与计费由该 CLI 自理。 +- **单活动连接模型**:PR 列表与状态栏只反映当前活动连接,切换后归档旧连接的 PR。 +- 新增面向用户的**使用说明**文档(`docs/guide/`):安装与首次使用、平台 / LLM / 代理配置、配置文件参考、自定义评审规则。 - 合并按钮等待态,防止重复点击。 -- 新增面向用户的**使用说明**文档(`docs/guide/`,序号命名 + 索引):安装与首次使用、代码平台配置、 - LLM 配置(含本地 CLI 模式)、网络代理、**配置文件参考**、**自定义评审规则**。 ### Changed -- 全仓内部命名统一为 **Bitbucket**,去除 `BBS` / `BB` 等歧义缩写(纯改名,无行为变化)。 -- 架构设计文档目录 `docs/modules/` → `docs/arch/`,统一定位为「架构设计文档」。 -- **启动提速**:新增启动闪屏(splash)即时呈现品牌 logo + spinner;Monaco(~7.3MB)改 `React.lazy` - 懒加载,渲染入口包 ~10MB → ~2.6MB,窗口外壳不再等 Monaco 解析;pr-agent 探测移出建窗关键路径 - 并发执行。 -- **日志增强**:dev 控制台改 logfmt 单行(` LEVEL msg="…" k=v`,按级别上色,文件仍 JSON); - 渲染层未捕获错误 / rejection 经 IPC 回传 main,与主进程崩溃兜底一并落进 `meebox.log`。 +- **启动提速**:新增启动闪屏即时呈现 logo + spinner;Monaco 改懒加载,渲染入口包 ~10MB → ~2.6MB;pr-agent 探测移出建窗关键路径。 +- 全仓内部命名统一为 **Bitbucket**,去除 `BBS` / `BB` 等歧义缩写(纯改名)。 +- 架构设计文档目录 `docs/modules/` → `docs/arch/`。 +- 日志增强:dev 控制台改 logfmt 单行(按级别上色,文件仍 JSON);渲染层未捕获错误经 IPC 回传 main 一并落日志。 ### Removed -- **移除 Docker 运行策略**:容器文件系统装载效率低、与「零依赖」定位不符;嵌入式运行时(默认)+ - 系统 local-cli 已覆盖全部场景。`pr_agent.strategy` 不再接受 `docker`。 +- **移除 Docker 运行策略**:嵌入式运行时 + 系统 local-cli 已覆盖全部场景,`pr_agent.strategy` 不再接受 `docker`。 ### Fixed -- 修复模型返回多行自由文本值(如中文 `issue_content`)未用块标量、续行顶格导致 pr-agent `load_yaml` - 解析失败、整个 `/review` 崩溃(`NoneType is not iterable`):`sitecustomize` 在解析失败时重排为块标量后重试。 -- 修复 pr-agent `get_diff_files` 对删除文件 filename 取空导致行号片段渲染崩溃(回退取 `a_path`)。 -- 修复首启向导平台卡视觉错位:GitHub 副标题缩短避免换行、图标固定宽度、文字在图标右侧区域居中。 +- 修复模型返回多行自由文本值导致 pr-agent YAML 解析失败、`/review` 崩溃。 +- 修复删除文件的行号片段渲染崩溃。 +- 修复首启向导平台卡视觉错位。 ### Security - GitHub 图片代理仅对可信的 GitHub / GHE 资产域附带 PAT,避免凭据被带往第三方域。 -- 升级 `nx` 至 22.7.5 并在范围内修复 `minimatch`,消除 `minimatch` ReDoS(high)依赖告警。 - -## [0.2.0-alpha.2] - 2026-06-09 - -> 开发期预览版。其全部变更内容已并入正式版 **[0.2.0](#020---2026-06-09)**,此处不再展开。 - -## [0.2.0-alpha.1] - 2026-06-09 - -> 开发期预览版。其全部变更内容已并入正式版 **[0.2.0](#020---2026-06-09)**,此处不再展开。 +- 升级 `nx` 至 22.7.5 并修复 `minimatch` ReDoS(high)依赖告警。 ## [0.1.0] - 2026-06-08 @@ -300,77 +223,57 @@ > 基于社区版 [pr-agent](https://docs.pr-agent.ai/) 构建:拉取待评审 PR、本地跑 AI 生成评审意见, > 逐条确认 / 编辑后再发布到代码平台。**决策权在人、规则在本地、数据在本地。** -### 平台接入与 PR 发现 - -- Bitbucket Server / Data Center 接入(REST API v1,>= 7.0)。 -- 轮询自动发现作为 Reviewer 的待评审 Open PR;按仓库分组、状态过滤、搜索。 -- 首启配置向导:引导配置代码平台连接 +(可选)LLM;缺有效连接时下次启动仍回向导。 -- 单例锁:二次启动聚焦已有窗口,不再多开。 - -### 本地 Diff 阅读 - -- bare 镜像(按需 clone / fetch)+ Monaco 并排 / 内联 diff。 -- 文件树、行内评论、git blame、跨文件代码搜索。 -- GitHub 风格未变更段折叠。 - -### AI 评审(pr-agent) - -- 对话式驱动 `/describe`、`/review`、`/ask`,输出结构化成可操作的 findings。 -- 评审任务队列:串行执行、排队任务在 chat 内可见、随时取消、失败重试。 -- `/review` finding 行号锚点根因修复(注入 get_line_link,从结构化输出取 file:line);finding 锚点可点击跳转到 Diff 对应行。 -- 真实 token 用量采集(输入 / 输出分列)。 -- LLM 未配置时 chat 面板给出明确提示并禁用输入。 - -### 评审 → 发布闭环 - -- findings → 草稿池 → 行内编辑(Monaco view zone)→ 单条 / 批量发布到远端。 -- 发布后远端评论自动刷新;重复发布幂等(发完即删本地草稿)。 -- 自己作者的远端评论支持回复 / 编辑 / 删除。 -- 远端可合并时一键合并 PR;审批 / 合并远端失败时弹 toast 提示,不再静默。 - -### 个性化规则 - -- 每位 Reviewer 维护自己的规则目录(markdown + frontmatter),按项目 / 仓库 / 目标分支命中后注入评审。 - -### 多 LLM Provider - -- 适配并实测验证:OpenAI、Anthropic、DeepSeek、阿里百炼(通义千问)、火山方舟(豆包)。 -- 厂商原厂模型只填型号名即用(按 provider 自动补 litellm 前缀)。 -- ollama / openai-compatible 理论可行(待验证)。 -- 设置页连接 / LLM / 代理可视化 CRUD(草稿态「写入不启用」,保存或显式启用才应用)。 -- 出站 HTTP 代理:LLM 调用 / 代码平台 / git HTTPS 统一走代理,本地地址自动直连。 - -### 运行时与打包 - -- 内嵌可重定位 Python + 固定版本 pr-agent,开箱即用,无需自装 Python / Docker(Docker 模式可选)。 -- 桌面安装包:Windows x64(NSIS)、macOS arm64(dmg,ad-hoc 签名、未公证)。 -- `sitecustomize` 无侵入补丁体系(带版本守卫):二进制安全 diff、Anthropic 新模型去 `temperature`、 - YAML 容错(anchor marker 不破坏解析)、token 用量采集等。 -- 修复:只读安装目录(如 `C:\Program Files`)下缺 `.secrets.toml` 导致的 pr-agent 启动告警 —— 占位文件改为组装期烤入随包分发。 - -### 隐私与数据 +### Added -- 本地优先:除调用所配置的 LLM API 与代码平台外不向第三方上报数据。 -- 配置 / 状态 / 日志固定在 `~/.code-meeseeks/`;仓库镜像目录可配置。 +- **平台接入与 PR 发现** + - Bitbucket Server / Data Center 接入(REST API v1,>= 7.0)。 + - 轮询自动发现作为 Reviewer 的待评审 Open PR;按仓库分组、状态过滤、搜索。 + - 首启配置向导:引导配置代码平台连接 +(可选)LLM;缺有效连接时下次启动仍回向导。 + - 单例锁:二次启动聚焦已有窗口,不再多开。 +- **本地 Diff 阅读** + - bare 镜像(按需 clone / fetch)+ Monaco 并排 / 内联 diff。 + - 文件树、行内评论、git blame、跨文件代码搜索。 + - GitHub 风格未变更段折叠。 +- **AI 评审(pr-agent)** + - 对话式驱动 `/describe`、`/review`、`/ask`,输出结构化成可操作的 findings。 + - 评审任务队列:串行执行、排队任务在 chat 内可见、随时取消、失败重试。 + - finding 行号锚点可点击跳转到 Diff 对应行。 + - 真实 token 用量采集(输入 / 输出分列)。 + - LLM 未配置时 chat 面板给出明确提示并禁用输入。 +- **评审 → 发布闭环** + - findings → 草稿池 → 行内编辑(Monaco view zone)→ 单条 / 批量发布到远端。 + - 发布后远端评论自动刷新;重复发布幂等(发完即删本地草稿)。 + - 自己作者的远端评论支持回复 / 编辑 / 删除。 + - 远端可合并时一键合并 PR;审批 / 合并远端失败时弹 toast 提示,不再静默。 +- **个性化规则** + - 每位 Reviewer 维护自己的规则目录(markdown + frontmatter),按项目 / 仓库 / 目标分支命中后注入评审。 +- **多 LLM Provider** + - 适配并实测验证:OpenAI、Anthropic、DeepSeek、阿里百炼(通义千问)、火山方舟(豆包)。 + - 厂商原厂模型只填型号名即用(按 provider 自动补 litellm 前缀)。 + - ollama / openai-compatible 理论可行(待验证)。 + - 设置页连接 / LLM / 代理可视化 CRUD(草稿态「写入不启用」,保存或显式启用才应用)。 + - 出站 HTTP 代理:LLM 调用 / 代码平台 / git HTTPS 统一走代理,本地地址自动直连。 +- **运行时与打包** + - 内嵌可重定位 Python + 固定版本 pr-agent,开箱即用,无需自装 Python / Docker(Docker 模式可选)。 + - 桌面安装包:Windows x64(NSIS)、macOS arm64(dmg,ad-hoc 签名、未公证)。 + - 对 pr-agent 的无侵入补丁体系:二进制安全 diff、新模型兼容、YAML 容错、token 用量采集等。 +- **隐私与数据** + - 本地优先:除调用所配置的 LLM API 与代码平台外不向第三方上报数据。 + - 配置 / 状态 / 日志固定在 `~/.code-meeseeks/`;仓库镜像目录可配置。 -## [0.1.0-alpha.1] - 2026-06-07 +### Fixed -> 首个公开预览版。其全部变更内容已并入正式版 **[0.1.0](#010---2026-06-08)**,此处不再重复展开。 +- 修复只读安装目录(如 `C:\Program Files`)下 pr-agent 启动告警。 --- 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 -[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.5.0-alpha.1...HEAD +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.6.0-alpha.1...HEAD +[0.6.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.5.0...v0.6.0-alpha.1 [0.5.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.4.0...v0.5.0 -[0.5.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.4.0...v0.5.0-alpha.1 [0.4.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.3.1...v0.4.0 -[0.4.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.3.1...v0.4.0-alpha.1 [0.3.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.2.0...v0.3.0 -[0.3.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.2.0...v0.3.0-alpha.1 [0.2.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.1.0...v0.2.0 -[0.2.0-alpha.2]: https://github.com/huhamhire/code-meeseeks/compare/v0.2.0-alpha.1...v0.2.0-alpha.2 -[0.2.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.1.0...v0.2.0-alpha.1 [0.1.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.1.0-alpha.1...v0.1.0 -[0.1.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/releases/tag/v0.1.0-alpha.1 diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index f880f9d6..7e18dc35 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; // 外部第三方依赖(electron / pino / yaml / zod ...)继续 externalize 让 Node 在运行时解析。 const internalPackages = [ '@meebox/shared', + '@meebox/ipc', '@meebox/agent', '@meebox/config', '@meebox/logger', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 887e7e8a..c5ceb899 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.5.0", + "version": "0.6.0-alpha.1", "private": true, "description": "meebox Electron desktop app", "author": { @@ -64,6 +64,7 @@ "@iconify/react": "^5.2.1", "@meebox/agent": "*", "@meebox/config": "*", + "@meebox/ipc": "*", "@meebox/logger": "*", "@meebox/platform-bitbucket-server": "*", "@meebox/platform-github": "*", diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py index ca51670f..28c36dfb 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py @@ -4,27 +4,37 @@ `LiteLLMAIHandler.chat_completion`——provider 路由、CLI 模式(MEEBOX_CLI_MODE)、Anthropic 去 temperature、token usage 哨兵全部继承,无需在此重复实现。 -约定:stdin 收一段 JSON `{"system": ..., "user": ..., "temperature"?: ...}`,回复正文写 stdout, -token 用量经 `@@MEEBOX_USAGE@@` 哨兵打到 stderr(主进程与 pr-agent run 同一套累加,见 ipc.ts)。 +约定:stdin 收一段 JSON `{"system": ..., "user": ..., "temperature"?: ..., "max_output_tokens"?: ...}`, +回复正文写 stdout,token 用量经 `@@MEEBOX_USAGE@@` 哨兵打到 stderr(主进程与 pr-agent run 同一套累加, +见 ipc.ts)。max_output_tokens 封顶输出(轻量路由判读用),经 env 中转给 litellm_handler 补丁注入 litellm +max_tokens——仅嵌入式 litellm 路径生效,CLI provider 忽略。 """ import asyncio import json +import os import sys def _read_payload() -> dict: raw = sys.stdin.read() if not raw.strip(): - return {"system": "", "user": "", "temperature": None} + return {"system": "", "user": "", "temperature": None, "max_output_tokens": None} data = json.loads(raw) return { "system": data.get("system") or "", "user": data.get("user") or "", "temperature": data.get("temperature"), + "max_output_tokens": data.get("max_output_tokens"), } async def _run(payload: dict) -> str: + # 输出封顶:每次 chat 独立子进程,故置环境变量即「本次调用」级别——litellm_handler 补丁里 + # 的 _get_completion 包装读它注入 litellm max_tokens(见 patches/litellm_handler)。 + mot = payload.get("max_output_tokens") + if isinstance(mot, int) and mot > 0: + os.environ["MEEBOX_CHAT_MAX_TOKENS"] = str(mot) + # 惰性 import:触发 shim 注册的 post-import 补丁(CLI 模式替换 / _get_completion usage 包装)。 from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.config_loader import get_settings diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py index 28c0d5fa..bedce97a 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py @@ -3,7 +3,7 @@ import os import sys -from ..runtime import _debug +from ..runtime import _debug, strip_cache_break from ..usage import _emit_usage_tokens from .specs import _CLI_SPECS @@ -28,7 +28,9 @@ def _install_cli_chat_completion(handler_cls, bin_name) -> None: 各命令差异(argv flags / 输出解析 / 需剥离的计费 env)集中在 _CLI_SPECS,按命令名取用: - prompt 经 **stdin** 喂入:review prompt 含完整 diff(数十 KB),走 argv 会撞命令行长度上限; system / user 拼成一段(CLI 单轮无独立 system 槽)。 - - cwd 落到中性临时目录:避免吃到被评审仓库的上下文(CLAUDE.md / AGENTS.md 等)污染输出。 + - cwd 默认落到中性临时目录:避免吃到被评审仓库的上下文(CLAUDE.md / AGENTS.md 等)污染输出。 + 例外:主进程仅对 /ask 经 MEEBOX_CLI_WORKDIR 下发(已净化的)worktree 路径,让自由问答能读到 + 完整文件;describe/review 不下发该 env、维持中性临时目录。净化在主进程侧做(清空仓库自带指令文件)。 - 子进程继承父 env(PATH / HOME / 代理变量),故能找到命令、复用其登录态、出站自动走代理。 - **凭据隔离**:剥掉对应计费 key(claude: ANTHROPIC_*;codex: OPENAI_API_KEY / CODEX_API_KEY), 让 CLI 使用其自身登录会话,而非环境里残留的 API key。模型与额度由该 CLI 账户与用户授权决定。""" @@ -61,6 +63,9 @@ async def chat_completion(self, model, system, user, temperature=0.2, img_path=N f"找不到本地 CLI 命令 '{bin_name}':请确认已安装、已登录,且 '{bin_name}' 在 PATH 中。" ) argv = _build_argv() + # CLI 单轮无独立 system 槽:system+user 拼一段。先剥除缓存断点标记(仅 Anthropic litellm 路径用于 + # 分块缓存;CLI 不缓存、标记不得进入 prompt)。 + system = strip_cache_break(system) if system else system prompt = f"{system}\n\n\n{user}" if system else user # 基于 os.environ 拷贝再剔除计费 key——其余(PATH/HOME/代理变量等)原样保留。 child_env = {k: v for k, v in os.environ.items() if k not in spec["strip_env"]} @@ -70,7 +75,7 @@ async def chat_completion(self, model, system, user, temperature=0.2, img_path=N stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - cwd=tempfile.gettempdir(), + cwd=(os.environ.get("MEEBOX_CLI_WORKDIR") or "").strip() or tempfile.gettempdir(), env=child_env, ) except Exception as exc: # noqa: BLE001 @@ -83,13 +88,26 @@ async def chat_completion(self, model, system, user, temperature=0.2, img_path=N ) text, usage = spec["parser"]((out or b"").decode("utf-8", "replace")) if usage: - # input_tokens(+cache_*) ≈ prompt,output_tokens ≈ completion(两家 usage 同字段名) + # prompt_tokens ≈ 输入侧总规模,output_tokens ≈ completion(input/output_tokens 两家同名)。 + # 缓存字段两家约定不同: + # - Anthropic(claude):input_tokens **不含**缓存,cache_read/创建需累加进总量; + # cache_read 用 cache_read_input_tokens。 + # - OpenAI(codex):input_tokens **已含**缓存,cached_input_tokens 仅作命中量、不再计入总量。 prompt_tokens = usage.get("input_tokens") for k in ("cache_read_input_tokens", "cache_creation_input_tokens"): v = usage.get(k) if isinstance(v, int): prompt_tokens = (prompt_tokens or 0) + v - _emit_usage_tokens(prompt_tokens, usage.get("output_tokens")) + cache_read = usage.get("cache_read_input_tokens") + if not isinstance(cache_read, int): + cache_read = usage.get("cached_input_tokens") # codex/OpenAI 风格 + turns = usage.get("num_turns") + _emit_usage_tokens( + prompt_tokens, + usage.get("output_tokens"), + cache_read_tokens=cache_read if isinstance(cache_read, int) else None, + turns=turns if isinstance(turns, int) else None, + ) return text, "stop" handler_cls.chat_completion = chat_completion diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py index 85b4968f..13d89d12 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py @@ -4,8 +4,12 @@ def _parse_claude_output(stdout): """解析 `claude -p --output-format json` 的 stdout,返回 (text, usage_dict_or_None)。 - 成功形如 {"result": "...", "usage": {"input_tokens":..,"output_tokens":..}, "is_error": false}。 - 非 JSON / 缺字段退化为「整段 stdout 当文本、usage=None」;仅 is_error=True 时抛错。""" + 成功形如 {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":.., + "cache_read_input_tokens":..}, "is_error": false}。非 JSON / 缺字段退化为「整段 stdout 当文本、 + usage=None」;仅 is_error=True 时抛错。 + + claude -p 是 agentic 多轮:顶层 num_turns 为本次会话内部的模型轮次(可远大于 1),把它并入 + usage dict 的 num_turns 字段一并上抛(usage 同字段名供采集层统一读取,见 install.py)。""" import json s = (stdout or "").strip() @@ -23,18 +27,25 @@ def _parse_claude_output(stdout): if not isinstance(text, str): text = s usage = obj.get("usage") - return text, (usage if isinstance(usage, dict) else None) + if not isinstance(usage, dict): + return text, None + nt = obj.get("num_turns") + if isinstance(nt, int): + usage["num_turns"] = nt + return text, usage def _parse_codex_output(stdout): """解析 `codex exec --json` 的 JSONL 事件流,返回 (text, usage_dict_or_None): - type==item.completed 且 item.type==agent_message → item.text 为模型回复,取最后一条; - - type==turn.completed → usage {input_tokens, output_tokens} 为 token。 + - type==turn.completed → usage {input_tokens, output_tokens} 为 token,并计一轮。 + turn.completed 出现次数作模型轮次 num_turns(并入 usage dict,与 claude 路径同字段名)。 逐行容错:非 JSON 行跳过、事件缺字段不致命;text 缺失退到空串(让上层 load_yaml 兜底)。""" import json text = None usage = None + turns = 0 for line in (stdout or "").splitlines(): line = line.strip() if not line: @@ -53,7 +64,10 @@ def _parse_codex_output(stdout): if isinstance(txt, str): text = txt # 取最后一条 agent_message 作最终回复 elif etype == "turn.completed": + turns += 1 u = ev.get("usage") if isinstance(u, dict): usage = u + if isinstance(usage, dict) and turns: + usage["num_turns"] = turns return (text if isinstance(text, str) else ""), usage diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py index b4d59c5d..d178c1ec 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py @@ -16,10 +16,19 @@ }, # codex:exec 非交互 + --json(JSONL 事件流);末位 `-` 让 stdin 作完整 prompt; # --skip-git-repo-check 容许临时目录运行,--sandbox read-only 只读不改文件。 - # 低算力档:-c model_reasoning_effort=minimal(codex 默认推理较重,编排通道无需,调低提速)。 + # 默认禁用 web_search / image_gen:评审与编排在只读临时目录里跑,这两个工具用不到, + # 关掉既收敛工具面、又省 ~3K tokens(工具定义不再随每次请求下发)。键值: + # web_search 是字符串枚举(disabled / cached / live),用 `-c web_search=disabled`; + # image_gen 是 feature flag,用 `-c features.image_generation=false`(等价 --disable image_generation)。 + # 低算力档:-c model_reasoning_effort=low(codex 默认推理较重,编排通道无需,调低提速)。 + # 不用 minimal:gpt-5.x-codex 不支持 minimal(仅 none/low/medium/high/xhigh,传 minimal 报 400), + # 且 minimal 还与 web_search / image_gen 互斥;low 普遍受支持、与工具兼容,作低算力档更稳。 "codex": { - "flags": ["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "-"], - "low_effort_flags": ["-c", "model_reasoning_effort=minimal"], + "flags": [ + "exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", + "-c", "web_search=disabled", "-c", "features.image_generation=false", "-", + ], + "low_effort_flags": ["-c", "model_reasoning_effort=low"], "parser": _parse_codex_output, "strip_env": ("OPENAI_API_KEY", "CODEX_API_KEY"), }, diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py index 15040d9e..6cdcee0b 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py @@ -18,8 +18,8 @@ "this exact structure: (1) the intro line \\'The following are alternative approaches to this PR:\\'; " "(2) then 2-4 plausible ALTERNATIVE implementation approaches, EACH formatted as a
block " "with BLANK LINES inside so the body is parsed as markdown (this exact layout, blank lines required): " - "a line '
N. concise PLAIN-TEXT approach title (NO backticks or markdown inside the " - "summary)', then a blank line, then 1-3 sentences explaining the approach and its main " + "a line '
N. concise approach title (you MAY use `inline code` for identifiers in the " + "summary; keep it short)', then a blank line, then 1-3 sentences explaining the approach and its main " "trade-off (you MAY use `inline code` for identifiers in this body), then a blank line, then the line " "'
'; (3) after the last
leave ONE blank line, then a paragraph starting with " "**Recommendation:** that compares the current approach against the alternatives and recommends one. " diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py index f581a43f..7d057015 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py @@ -7,9 +7,85 @@ import os from ..cli.install import _install_cli_chat_completion -from ..runtime import _EXPECTED_PRAGENT_VERSION, _debug, _pragent_version +from ..runtime import ( + _EXPECTED_PRAGENT_VERSION, + _debug, + _pragent_version, + split_cache_break, +) from ..usage import _emit_usage +# Anthropic 提示缓存最小可缓存粒度约 1k token;稳定前缀低于此不标缓存(含极小的判读 system)。 +_CACHE_MIN_CHARS = 4000 +# 全局稳定前缀用 1h 扩展 TTL:跨所有 PR/运行在 1h 内命中,写入 2× 由大量命中摊薄;需带 beta 头。 +_CACHE_TTL = "1h" +_CACHE_BETA_FLAG = "extended-cache-ttl-2025-04-11" + + +def _add_cache_beta_header(kwargs: dict) -> None: + """合入 1h 缓存所需 anthropic-beta 头(不覆盖既有标志)。""" + headers = kwargs.get("extra_headers") + if not isinstance(headers, dict): + headers = {} + existing = headers.get("anthropic-beta") + if not existing: + headers["anthropic-beta"] = _CACHE_BETA_FLAG + elif _CACHE_BETA_FLAG not in existing: + headers["anthropic-beta"] = f"{existing},{_CACHE_BETA_FLAG}" + kwargs["extra_headers"] = headers + + +def _apply_system_prompt_cache(kwargs: dict) -> None: + """为 Anthropic 给 system 的稳定前缀标 cache_control(1h 扩展 TTL);并在任何情况下剥除 CACHE_BREAK 标记。 + Anthropic 提示缓存按**前缀**、**服务端**生效(不依赖暖会话),跨所有 PR/运行在 1h 内命中(写入 2× 由命中摊薄)。 + 覆盖两类 Anthropic 调用: + + 1) 编排 chat 通道(MEEBOX_CHAT_CACHE 置位、system 含 assembleSystemContext 插入的 CACHE_BREAK):按断点把 + 全局稳定前缀(SOUL/AGENTS/工具/记忆/用户)单独标缓存、PR/运行相关尾部保持纯文本。 + 2) pr-agent 工具 run(/review /describe /improve /ask,**无** CACHE_BREAK):system 即 pr-agent 的指令 + + 输出格式(约 12k 字符,仅随配置/语言/规则变、跨 PR 稳定;可变的 diff 在 user 侧),整段标缓存 → 同配置下 + 跨运行 1h 内命中。 + + 非 Anthropic(OpenAI/DeepSeek 等):自带自动前缀缓存、无需显式标,仅剥除 CACHE_BREAK 标记拼回纯文本;前缀 + 过小(< _CACHE_MIN_CHARS,如精简判读 system)也不标。 + """ + msgs = kwargs.get("messages") + if not isinstance(msgs, list): + return + is_anthropic = (kwargs.get("model") or "").lower().startswith("anthropic/") + chat_cache_on = bool(os.environ.get("MEEBOX_CHAT_CACHE")) + for m in msgs: + if m.get("role") != "system" or not isinstance(m.get("content"), str): + continue + stable, variable = split_cache_break(m["content"]) + if stable is not None: + # 含 CACHE_BREAK(编排 chat):稳定前缀标缓存、尾部纯文本 + if chat_cache_on and is_anthropic and len(stable) >= _CACHE_MIN_CHARS: + m["content"] = [ + { + "type": "text", + "text": stable, + "cache_control": {"type": "ephemeral", "ttl": _CACHE_TTL}, + }, + {"type": "text", "text": variable}, + ] + _add_cache_beta_header(kwargs) + else: + # 非 anthropic / 未开缓存 / 前缀过小:去标记拼回纯文本(自动前缀缓存仍可命中)。 + m["content"] = f"{stable}\n\n---\n\n{variable}" + return + # 无 CACHE_BREAK(pr-agent 工具 run):Anthropic 把整段稳定 system 标缓存(diff 在 user 侧、不进缓存)。 + if is_anthropic and len(m["content"]) >= _CACHE_MIN_CHARS: + m["content"] = [ + { + "type": "text", + "text": m["content"], + "cache_control": {"type": "ephemeral", "ttl": _CACHE_TTL}, + }, + ] + _add_cache_beta_header(kwargs) + return + def patch(module) -> None: """(1) 新版 Anthropic 原厂模型(claude-opus-4-8 等)弃用 temperature 参数,但 pr-agent 默认 @@ -24,6 +100,17 @@ def patch(module) -> None: 容器:凡走 anthropic 原厂的模型一律不发 temperature。LiteLLMAIHandler.__init__ 里 `self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS` 取的是模块全局名,故重绑 全局即对之后创建的 handler 生效;只动成员判定、不碰 system/user 合并。""" + # 抑制 litellm 往 **stdout** 打的「Provider List: …」等装饰性提示(ANSI 红字)。编排 chat 通道以子进程 + # stdout 作模型回复:litellm 在 cost/token 计量里对未进本地 model_cost 表的新模型(如 claude-opus-4-8) + # 调 get_llm_provider 失败时会先 print 该提示再抛错(错误被上游吞掉、不影响最终结果),但 print 已污染 + # stdout、漏进评审总结。置 suppress_debug_info=True 关掉这些 print(真实 usage 由我们自己的 hook 采集, + # 不依赖这些输出)。全局生效、与 pr-agent 版本无关,故放在版本守卫与 CLI 分支之前。 + try: + import litellm + + litellm.suppress_debug_info = True + except Exception: # noqa: BLE001 - litellm 未就绪等,纯装饰性抑制失败不致命 + pass # (0) CLI 模式:换 chat_completion 直接调本机 CLI,绕过 litellm。放在版本守卫之前, # 因为它只依赖 base_ai_handler 的稳定契约,跟 pr-agent 内部实现无关。装好即 return。 if os.environ.get("MEEBOX_CLI_MODE"): @@ -61,6 +148,16 @@ def __contains__(self, model) -> bool: _orig_get_completion = handler_cls._get_completion async def _get_completion_with_usage(self, **kwargs): + # 输出封顶(编排器 chat 通道经 MEEBOX_CHAT_MAX_TOKENS 设;pr-agent 工具 run 的 env 不含 + # 该项,故 /describe /review 不受限)。"thinking" 在场(Claude 扩展思考)时不覆盖其 max_tokens + # (否则会低于 thinking budget 报错);已有 max_tokens 也不覆盖。 + mt = os.environ.get("MEEBOX_CHAT_MAX_TOKENS") + if mt and "thinking" not in kwargs and "max_tokens" not in kwargs: + try: + kwargs["max_tokens"] = int(mt) + except (TypeError, ValueError): + _debug(f"ignore invalid MEEBOX_CHAT_MAX_TOKENS={mt!r}") + _apply_system_prompt_cache(kwargs) result = await _orig_get_completion(self, **kwargs) try: if isinstance(result, tuple) and len(result) >= 3: diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py index fa8e4a2b..541fb196 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py @@ -68,6 +68,18 @@ def get_diff_files(self): module.LocalGitProvider.get_diff_files = get_diff_files + # _prepare_repo: 上游在 repo.is_dirty() 时抛「repository is not in a clean state」。我们对 CLI 模式 + # /ask 的 worktree 会按需净化——截断仓库自带的 agent 指令文件(CLAUDE.md / AGENTS.md / .cursor 规则 + # 等,防 CLI 子进程自动加载污染回答);若这些文件被仓库纳入版本管理,净化即让工作区变「脏」,触发该守卫 + # → 整个 /ask 在取 git provider 阶段就崩、不写 review.md。而 diff 取自分支提交(head.commit vs + # merge-base,见 get_diff_files),与工作区是否脏无关,故脏检查对这套「一次性受控 worktree」是误报。 + # 只保留必需的「目标分支存在」校验,去掉脏检查。 + def _prepare_repo(self): + if self.target_branch_name not in self.repo.heads: + raise KeyError(f"Branch: {self.target_branch_name} does not exist") + + module.LocalGitProvider._prepare_repo = _prepare_repo + # get_line_link: 基类默认 `return ''`,LocalGitProvider 未实现 → /review 的 # key_issues 渲染(convert_to_markdown_v2)走"无 link + 非 GFM"分支,把 # relevant_file/start_line/end_line 抹掉(见 ROADMAP M5 anchor 根因)。补成 diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py index 6bcf3a7c..7c4bb755 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py @@ -16,6 +16,25 @@ # (assemble 脚本会校验两者一致,并据本文件抽取该常量),并重新验证 patch 行为。 _EXPECTED_PRAGENT_VERSION = "0.36.0" +# 系统上下文「缓存断点」标记:assembleSystemContext(TS, packages/agent/src/assemble.ts)在**全局稳定 +# 前缀**(SOUL/AGENTS/工具目录/记忆/用户档)与 **PR/运行相关尾部** 之间插入此串(连同两侧 --- 分隔)。 +# shim 据此把稳定前缀单独标 Anthropic 提示缓存(1h),尾部保持纯文本;消费端(litellm 分块 / CLI 拼接) +# 分割或剥除后,标记**绝不**进入发给模型的 prompt。两处常量须逐字一致。 +CACHE_BREAK = "\n\n---\n\n[[MEEBOX:CACHE_BREAK]]\n\n---\n\n" + + +def split_cache_break(system): + """按缓存断点切分 system → (stable_prefix, variable_tail)。无断点返回 (None, system)。""" + stable, sep, variable = system.partition(CACHE_BREAK) + if not sep: + return None, system + return stable, variable + + +def strip_cache_break(system): + """剥除缓存断点标记(不分块的消费端用,如 CLI prompt 拼接),塌成单个 --- 分隔。""" + return system.replace(CACHE_BREAK, "\n\n---\n\n") + def _debug(msg) -> None: if os.environ.get("MEEBOX_SHIM_DEBUG"): diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py index 3f453728..9f8de2ab 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py @@ -27,14 +27,29 @@ def _g(key): } if rec["prompt_tokens"] is None and rec["completion_tokens"] is None: return # 没有任何可用数字(如流式 MockResponse)→ 不打 + # 提示缓存读取量:Anthropic 走 cache_read_input_tokens;OpenAI 兼容走 + # prompt_tokens_details.cached_tokens。两路尽力采集、缺失则不带(UI 据有无决定是否展示)。 + cache_read = _g("cache_read_input_tokens") + if not isinstance(cache_read, int): + details = _g("prompt_tokens_details") + if details is not None: + cache_read = ( + details.get("cached_tokens") + if isinstance(details, dict) + else getattr(details, "cached_tokens", None) + ) + if isinstance(cache_read, int): + rec["cache_read_tokens"] = cache_read print(f"@@MEEBOX_USAGE@@ {json.dumps(rec)}", file=sys.stderr, flush=True) except Exception as exc: # noqa: BLE001 _debug(f"emit usage failed (ignored): {exc}") -def _emit_usage_tokens(prompt_tokens, completion_tokens) -> None: +def _emit_usage_tokens( + prompt_tokens, completion_tokens, cache_read_tokens=None, turns=None +) -> None: """CLI 模式下从 CLI 返回的 JSON usage 直接构造哨兵(与 _emit_usage 同格式,主进程同一套 - 累加逻辑)。两个数都为 None 则不打。""" + 累加逻辑)。两个 token 数都为 None 则不打;cache_read / turns 仅在有值时附带。""" try: if prompt_tokens is None and completion_tokens is None: return @@ -45,6 +60,10 @@ def _emit_usage_tokens(prompt_tokens, completion_tokens) -> None: "completion_tokens": completion_tokens, "total_tokens": (prompt_tokens or 0) + (completion_tokens or 0), } + if cache_read_tokens is not None: + rec["cache_read_tokens"] = cache_read_tokens + if turns is not None: + rec["turns"] = turns print(f"@@MEEBOX_USAGE@@ {json.dumps(rec)}", file=sys.stderr, flush=True) except Exception as exc: # noqa: BLE001 _debug(f"emit cli usage failed (ignored): {exc}") diff --git a/apps/desktop/src/main/bootstrap/connections-runtime.ts b/apps/desktop/src/main/bootstrap/connections-runtime.ts new file mode 100644 index 00000000..b12a07f4 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/connections-runtime.ts @@ -0,0 +1,112 @@ +import type { BootstrapResult } from '@meebox/config'; +import type { Poller } from '@meebox/poller'; +import type { PlatformAdapter, PlatformUser } from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { Logger } from 'pino'; +import { buildAdapters, type ConnectionRuntime } from '../adapters.js'; +import { writeConnectionStates, type ConnectionState } from '../utils/connection-state.js'; + +/** + * 连接运行时控制器:把启动序列里的「连接接线 / ping / 热重配」从 index.ts 收口。「接线」与「ping」解耦, + * 实现「启动不依赖网络」——见各方法注释。运行态(runtime + 连接级本地状态)是实例可变状态,故以 class 封装。 + */ +export class ConnectionRuntimeController { + /** 可变持有的连接运行时(adapters 全量 + adapterByHost);IPC / repoMirror 经引用读到最新值。 */ + readonly runtime: ConnectionRuntime = { adapters: [], adapterByHost: new Map() }; + + constructor( + private readonly bootstrap: BootstrapResult, + private readonly stateStore: JsonFileStateStore, + private readonly poller: Poller, + private readonly logger: Logger, + /** 启动时载入的连接级本地状态(含上次 ping 的 currentUser);随 ping 增量回写。 */ + private connectionStates: Record, + ) {} + + /** 当前启用连接的 id 列表(poller.archiveConnectionsExcept 用)。 */ + activeConnectionIds(): string[] { + return this.runtime.adapters + .filter((a) => a.connectionId === this.bootstrap.config.active_connection_id) + .map((a) => a.connectionId); + } + + /** 重建 adapters/byHost、用本地持久化身份预热 currentUser、把活动连接喂给 poller(同步、无网络,可在建窗前调)。 */ + wire(): void { + const adapters = buildAdapters(this.bootstrap.config.connections, this.bootstrap.config.proxy); + const byHost = new Map(); + for (const { connectionId, adapter } of adapters) { + // 预热 currentUser:有本地记录就先填上(无记录则保持 null,由 ping 兜底)。 + const cachedUser = this.connectionStates[connectionId]?.user; + if (cachedUser) adapter.setCurrentUser?.(cachedUser); + const conn = this.bootstrap.config.connections.find((c) => c.id === connectionId); + if (!conn) continue; + try { + byHost.set(new URL(conn.base_url).hostname, adapter); + } catch (err) { + this.logger.warn({ err, connectionId, base_url: conn.base_url }, 'invalid base_url'); + } + } + this.runtime.adapters = adapters; + this.runtime.adapterByHost = byHost; + // 只轮询当前启用的连接(同时仅一条);其余仅保留配置不轮询。 + this.poller.setConnections( + adapters.filter((a) => a.connectionId === this.bootstrap.config.active_connection_id), + ); + } + + /** 全异步 ping:刷新远端身份并增量持久化;活动连接身份变化(含首次取得)则补一轮 poll(有网络,不在启动关键路径)。 */ + ping(): void { + const activeId = this.bootstrap.config.active_connection_id; + for (const { connectionId, adapter } of this.runtime.adapters) { + const isActive = connectionId === activeId; + const beforeName = adapter.getCurrentUser()?.name ?? null; + // 活动连接启动时无缓存身份 → poller.start(immediate=false) 没跑首轮;此处 ping settle 后必须触发 + // **首次同步**(无论 ping 成功与否):「先确认身份,再立即同步一次」。 + const hadIdentity = beforeName !== null; + void adapter.ping().then( + async (r) => { + this.logger.info( + { connectionId, ok: r.ok, serverVersion: r.serverVersion, user: r.user?.name }, + 'adapter ping', + ); + const user = adapter.getCurrentUser(); + await this.persistConnectionUser(connectionId, user); + // 触发重分类/首次同步:活动连接且(身份变化 含首次取得/换号,或本就无身份需补首轮)。 + if (isActive && (!hadIdentity || (user?.name ?? null) !== beforeName)) { + void this.poller.tick(); + } + }, + (err: unknown) => { + this.logger.warn({ err, connectionId }, 'adapter ping failed'); + // ping 失败但活动连接本就无缓存身份(首轮被跳过)→ 仍用 PAT 兜底同步一次,避免看似没同步。 + if (isActive && !hadIdentity) void this.poller.tick(); + }, + ); + } + } + + /** 设置页改连接 / 代理后的热生效:重接线 + 归档非活动连接(本地 IO)+ 异步 ping。 */ + async reconfigure(): Promise { + this.wire(); + await this.poller.archiveConnectionsExcept(this.activeConnectionIds()); + this.ping(); + } + + /** 持久化某连接的 currentUser(仅身份变化时写盘,避免无谓 IO)。写盘失败不影响运行。 */ + private async persistConnectionUser( + connectionId: string, + user: PlatformUser | null, + ): Promise { + const prevName = this.connectionStates[connectionId]?.user?.name ?? null; + if (prevName === (user?.name ?? null)) return; + this.connectionStates = { + ...this.connectionStates, + [connectionId]: { ...this.connectionStates[connectionId], user }, + }; + try { + await writeConnectionStates(this.stateStore, this.connectionStates); + } catch (err) { + this.logger.warn({ err, connectionId }, 'persist connection user failed'); + } + } +} diff --git a/apps/desktop/src/main/bootstrap/index.ts b/apps/desktop/src/main/bootstrap/index.ts new file mode 100644 index 00000000..09c0647a --- /dev/null +++ b/apps/desktop/src/main/bootstrap/index.ts @@ -0,0 +1,13 @@ +/** + * 应用启动装配域:OS/平台启动微调 + 各运行时(pr-agent / 连接 / 窗口 / 轮询 / 镜像 / 版本检测 / splash) + * 的 init/factory。index.ts 作为组合根从此处取用、装配;各模块只依赖 ../(context / services / utils / + * adapters)与库包。 + */ +export { applyOsStartupTweaks } from './os-startup-tweaks.js'; +export { PrAgentRuntime } from './pragent-runtime.js'; +export { ConnectionRuntimeController } from './connections-runtime.js'; +export { createPoller } from './poller.js'; +export { createRepoMirror } from './repo-mirror.js'; +export { WindowManager, loadWindowManager } from './window-manager.js'; +export { createSplash } from './splash.js'; +export { Updater } from './updater.js'; diff --git a/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts b/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts new file mode 100644 index 00000000..6585b664 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts @@ -0,0 +1,75 @@ +import { execSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { app } from 'electron'; + +// 常见 CLI 安装目录:覆盖 pip --user / npm global / homebrew(Apple Silicon + Intel)。 +const COMMON_CLI_DIRS = [ + path.join(os.homedir(), '.local', 'bin'), + '/usr/local/bin', + '/opt/homebrew/bin', + '/opt/homebrew/sbin', +]; + +/** + * macOS GUI(Finder / Dock / LaunchServices)启动的 app 由 launchd 给出**最小 PATH** + * (`/usr/bin:/bin:/usr/sbin:/sbin`),**不读用户 shell 配置**(`.zshrc` / `.zprofile`)。 + * 而本机 CLI(claude / codex)常装在 `~/.local/bin`、homebrew 等**只由 shell 往 PATH 里加**的 + * 目录——于是嵌入式 python 的 `shutil.which(...)` 找不到命令、本地 CLI provider 失效,但从终端 + * `npm run dev` 启动却正常(继承了已加载配置的终端 PATH)。Windows 不受影响(GUI 进程继承用户 PATH)。 + * + * 把常见目录前置进 `process.env.PATH`(去重,只补原 PATH 缺失的,保持原有顺序在后);之后所有子进程 + * (嵌入式 python 及其 spawn 的 CLI)都经 `{ ...process.env }` 继承到。静态目录已覆盖最常见的安装位置; + * 不跑登录 shell 解析(避免启动期子进程 / 超时 / 噪声)。仅由 applyMacStartupTweaks 调用。 + */ +function augmentMacPath(): void { + const existing = (process.env.PATH ?? '').split(':').filter(Boolean); + const existingSet = new Set(existing); + const added = COMMON_CLI_DIRS.filter((d) => !existingSet.has(d)); + if (added.length > 0) { + process.env.PATH = [...added, ...existing].join(':'); + } +} + +/** + * Windows 专属启动微调:附着控制台默认本地化 OEM 页(简中 cp936/GBK),与 pino 的 UTF-8 字节对不上 → + * dev 终端中文日志乱码;chcp 65001 把输出代码页切到 UTF-8 对齐。无控制台(打包态)chcp 静默失败、已吞, + * 无副作用。 + */ +function applyWindowsStartupTweaks(): void { + try { + execSync('chcp 65001', { stdio: 'ignore' }); + } catch { + /* 无控制台 / chcp 不可用:忽略,日志仍按 UTF-8 字节写出 */ + } +} + +/** + * macOS 专属启动微调: + * - use-mock-keychain:ad-hoc 签名身份不稳定(cdhash 每次构建变),os_crypt 每次启动弹「访问钥匙串」; + * mock 让其走内存不碰真钥匙串。代价:cookie 加密退化为静态 key,但密钥本就明文落盘,无实质损失。 + * 有正式 Developer ID 签名后可移除。须在 app.whenReady() 之前。 + * - PATH 前置常见 CLI 目录(见 augmentMacPath):须在 pr-agent 探测 / 运行前。 + */ +function applyMacStartupTweaks(): void { + app.commandLine.appendSwitch('use-mock-keychain'); + augmentMacPath(); +} + +/** + * 进程 / 平台启动微调(须在模块加载期、app.whenReady() 之前跑一次):先做跨平台的进程 env 调整,再按 + * 当前平台委托各自的专属初始化(见 applyWindowsStartupTweaks / applyMacStartupTweaks)。 + * + * 跨平台:PYTHONDONTWRITEBYTECODE=1——嵌入式 python 子进程不落 .pyc(安装目录 per-user 可写,运行期会 + * 积累上万 __pycache__/.pyc 拖慢升级卸载);子进程经 spawn 继承本进程 env。代价:每次启动重编译(略慢), + * 影响有限。 + */ +export function applyOsStartupTweaks(): void { + process.env.PYTHONDONTWRITEBYTECODE = '1'; + + if (process.platform === 'win32') { + applyWindowsStartupTweaks(); + } else if (process.platform === 'darwin') { + applyMacStartupTweaks(); + } +} diff --git a/apps/desktop/src/main/bootstrap/poller.ts b/apps/desktop/src/main/bootstrap/poller.ts new file mode 100644 index 00000000..16b63711 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/poller.ts @@ -0,0 +1,54 @@ +import type { BootstrapResult } from '@meebox/config'; +import { Poller } from '@meebox/poller'; +import type { RepoMirrorManager } from '@meebox/repo-mirror'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { Logger } from 'pino'; +import { broadcast } from '../services/broadcast.js'; + +/** + * 构造轮询器:tick 广播 poll:tick + 触发顺带副作用(onTickExtras);PR 变更顺手 syncMirror 跟本地镜像。 + * 启动时不带连接(connections:[]),由 connections-runtime 的 wire/setConnections 注入;run/agent 等 + * 后绑定依赖(ipcControl / repoMirror)经回调与 getter 延迟取用(它们在 poller 之后才建好)。 + */ +export function createPoller(deps: { + bootstrap: BootstrapResult; + stateStore: JsonFileStateStore; + logger: Logger; + /** poll tick 顺带的副作用(清理消失 PR 的 agent 操作 / 版本检测 / AutoPilot 准入),由 index 绑定。 */ + onTickExtras: () => void; + /** 延迟取 repoMirror(它在 poller 之后才建好)。 */ + getRepoMirror: () => RepoMirrorManager; +}): Poller { + const { bootstrap, stateStore, logger } = deps; + return new Poller({ + connections: [], + stateStore, + intervalSeconds: bootstrap.config.poller.interval_seconds, + logger: logger.child({ scope: 'poller' }), + onTick: (info) => { + broadcast('poll:tick', info); + deps.onTickExtras(); + }, + // PR 新增 / 内容变更时顺手 syncMirror 跟上本地镜像,让用户随后点开 PR 省一趟 fetch。失败不阻断 poll + //(mirror 有自己的全局队列 + 错误隔离)。identity 字段映射:poller 用 group/repo,repo-mirror 仍保留 + // Bitbucket-shaped projectKey/repoSlug(跟 git 路径布局一致,沿用便于排障)。 + onPrsChanged: (repos) => { + for (const r of repos) { + const conn = bootstrap.config.connections.find((c) => c.id === r.connectionId); + if (!conn) continue; + let host: string; + try { + host = new URL(conn.base_url).hostname; + } catch { + continue; + } + void deps + .getRepoMirror() + .syncMirror({ host, projectKey: r.group, repoSlug: r.repo }) + .catch((err) => { + logger.warn({ err, repo: r }, 'auto syncMirror after poll failed'); + }); + } + }, + }); +} diff --git a/apps/desktop/src/main/bootstrap/pragent-runtime.ts b/apps/desktop/src/main/bootstrap/pragent-runtime.ts new file mode 100644 index 00000000..7ea566d6 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/pragent-runtime.ts @@ -0,0 +1,70 @@ +import path from 'node:path'; +import type { BootstrapResult } from '@meebox/config'; +import { createPrAgentBridge, type PrAgentBridge } from '@meebox/pr-agent-bridge'; +import type { PrAgentStatus } from '@meebox/shared'; +import { app } from 'electron'; +import type { Logger } from 'pino'; + +/** + * pr-agent 运行时:解析嵌入式解释器路径 + kick-off 探测(构造即开跑、不 await),结果异步回填。探测**不放 + * 在建窗关键路径**——它走 spawn 探测(auto 回退 local-cli 最坏 5s),await 会把首帧推迟数秒;改 kick-off + * 与 whenReady + 渲染层加载并发跑。bridge 由探测异步回填,故以 class 持有可变态。 + * - probe:app:prAgentStatus 据此 await 拿最终状态(boot 时序通常已完成)。 + * - getBridge():pragent run 入口读,未就绪时为 null → 走「未就绪」提示。 + */ +export class PrAgentRuntime { + /** 嵌入式解释器绝对路径(探测层据此判 embedded 是否可用,文件不存在则回退 local-cli)。 */ + readonly embeddedPythonPath: string; + /** 探测 promise(构造逻辑保证恒 resolve、不 reject)。 */ + readonly probe: Promise; + private bridge: PrAgentBridge | null = null; + + constructor( + private readonly bootstrap: BootstrapResult, + private readonly logger: Logger, + ) { + this.embeddedPythonPath = PrAgentRuntime.resolveEmbeddedPython(); + this.probe = this.kickoffProbe(); + } + + /** 探测完成前为 null。 */ + getBridge(): PrAgentBridge | null { + return this.bridge; + } + + /** + * 嵌入式 pr-agent 运行时的解释器绝对路径。 + * - dev:`apps/desktop/vendor/pragent/...`(app.getAppPath() = apps/desktop) + * - 打包:`/pragent/...`(electron-builder extraResources) + * - `MEEBOX_PRAGENT_PYTHON` env 覆盖兜底 + */ + private static resolveEmbeddedPython(): string { + const override = process.env.MEEBOX_PRAGENT_PYTHON; + if (override) return override; + const rel = + process.platform === 'win32' ? ['python', 'python.exe'] : ['python', 'bin', 'python3']; + const base = app.isPackaged + ? path.join(process.resourcesPath, 'pragent') + : path.join(app.getAppPath(), 'vendor', 'pragent'); + return path.join(base, ...rel); + } + + private kickoffProbe(): Promise { + return (async (): Promise => { + const probe = await createPrAgentBridge({ + embeddedPythonPath: this.embeddedPythonPath, + forceStrategy: this.bootstrap.config.pr_agent.strategy, + }); + this.bridge = probe.bridge; + this.logger.info( + { + available: probe.status.available, + strategy: probe.status.available ? probe.status.strategy : undefined, + version: probe.status.available ? probe.status.version : undefined, + }, + 'pr-agent probe complete', + ); + return probe.status; + })(); + } +} diff --git a/apps/desktop/src/main/bootstrap/repo-mirror.ts b/apps/desktop/src/main/bootstrap/repo-mirror.ts new file mode 100644 index 00000000..35da3b23 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/repo-mirror.ts @@ -0,0 +1,29 @@ +import type { BootstrapResult } from '@meebox/config'; +import { RepoMirrorManager } from '@meebox/repo-mirror'; +import type { Logger } from 'pino'; +import type { ConnectionRuntime } from '../adapters.js'; +import { broadcast } from '../services/broadcast.js'; +import { buildProxyEnv } from '../utils/proxy.js'; + +/** + * 构造本地仓库镜像管理器:clone url 经连接运行时按 host 取 adapter 求得(设置页改连接热生效,读 runtime 引用); + * 进度广播 sync:progress;出站代理 getter 每次远端 clone/fetch 求值(改代理即生效)。 + */ +export function createRepoMirror(deps: { + bootstrap: BootstrapResult; + logger: Logger; + connectionRuntime: ConnectionRuntime; +}): RepoMirrorManager { + const { bootstrap, logger, connectionRuntime } = deps; + return new RepoMirrorManager({ + reposDir: bootstrap.paths.reposDir, + getCloneUrl: async (repo) => { + const adapter = connectionRuntime.adapterByHost.get(repo.host); + if (!adapter) throw new Error(`no adapter for host ${repo.host}`); + return adapter.getCloneUrl({ projectKey: repo.projectKey, repoSlug: repo.repoSlug }); + }, + logger: logger.child({ scope: 'repo-mirror' }), + onProgress: (event) => broadcast('sync:progress', event), + proxyEnv: () => buildProxyEnv(bootstrap.config.proxy), + }); +} diff --git a/apps/desktop/src/main/bootstrap/splash.ts b/apps/desktop/src/main/bootstrap/splash.ts new file mode 100644 index 00000000..a7421192 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/splash.ts @@ -0,0 +1,71 @@ +import { app, BrowserWindow } from 'electron'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; + +/** + * 读取品牌 logo 并转成 base64 data URI,内联进 splash data URL(splash 是独立 data URL + * 文档,无法走 file:// 相对路径引用资源,故必须内联)。两路探测: + * - 打包态:`/icon.png`(electron-builder extraResources copy) + * - dev:仓库 `assets/icons/icon.png` + * 两路都读不到(如 LFS 未拉取)则返回 null,splash 优雅回退为纯 spinner。 + */ +function resolveSplashLogo(): string | null { + const candidates = [ + path.join(process.resourcesPath, 'icon.png'), + path.join(app.getAppPath(), '../../assets/icons/icon.png'), + ]; + for (const p of candidates) { + try { + const buf = readFileSync(p); + // LFS 指针文件不是合法 PNG(无 \x89PNG magic)→ 跳过,避免 splash 显示裂图 + if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) continue; + return `data:image/png;base64,${buf.toString('base64')}`; + } catch { + /* 试下一个候选 */ + } + } + return null; +} + +/** + * 启动闪屏:独立的无边框轻量窗口,加载内联 data URL(品牌 logo + 纯 CSS spinner), + * 几十 ms 即可呈现,遮住主窗口首帧前的渲染层加载空窗。主窗口 ready-to-show 时关闭。 + * logo 经 base64 内联(见 resolveSplashLogo),data URL 自包含、dev/打包行为一致。 + */ +export function createSplash(): BrowserWindow { + const splash = new BrowserWindow({ + width: 280, + height: 240, + frame: false, + resizable: false, + movable: false, + center: true, + show: false, + alwaysOnTop: true, + skipTaskbar: true, + backgroundColor: '#1e1e1e', + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + const logo = resolveSplashLogo(); + const logoEl = logo ? `` : ''; + const html = ` + ${logoEl}
Code Meeseeks
+
启动中…
+ `; + void splash.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); + splash.once('ready-to-show', () => { + if (!splash.isDestroyed()) splash.show(); + }); + return splash; +} diff --git a/apps/desktop/src/main/bootstrap/updater.ts b/apps/desktop/src/main/bootstrap/updater.ts new file mode 100644 index 00000000..a0acfcac --- /dev/null +++ b/apps/desktop/src/main/bootstrap/updater.ts @@ -0,0 +1,48 @@ +import type { BootstrapResult } from '@meebox/config'; +import { app } from 'electron'; +import type { Logger } from 'pino'; +import { checkForUpdate } from '../utils/update-check.js'; +import { publishUpdateResult } from '../utils/update-state.js'; + +// 至多每小时一次(复用 poller 周期,不另起定时器)。 +const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; + +/** + * 版本更新检测节流器:由 poller tick 顺带调 runIfDue,内部时间戳门控至多每小时一次。lastCheckMs 初值取 + * 构造时刻 → 首次检测落在启动后约 1h,刻意不在启动瞬间检测(避免占冷启动网络 / 打断启动)。仅检测 + 提示: + * 有新版才广播给所有窗口;失败静默(绝不推任何 IPC,对用户零打扰)。节流状态是实例字段,故以 class 封装。 + */ +export class Updater { + private lastCheckMs = Date.now(); + + constructor( + private readonly bootstrap: BootstrapResult, + private readonly logger: Logger, + ) {} + + /** 满足开关 + 距上次满 1h 时发起一次检测。时间戳在 await 前更新,避免窗口内下一次 tick 重复发起。 */ + async runIfDue(): Promise { + if (!this.bootstrap.config.update.check_enabled) return; + if (Date.now() - this.lastCheckMs < UPDATE_CHECK_INTERVAL_MS) return; + this.lastCheckMs = Date.now(); + try { + const result = await checkForUpdate(app.getVersion(), this.bootstrap.config.proxy); + // 获取失败(网络 / 解析 / 超时 / 限流,ok=false):只记 debug,**绝不推任何 IPC** → 用户无感。 + if (!result.ok) { + this.logger.debug({ error: result.error }, 'update check failed (silent, no prompt)'); + return; + } + // 交给单一真相源:缓存结果,仅在确有新版时广播(与设置页手动检查共用同一路径)。 + publishUpdateResult(result); + if (result.hasUpdate) { + this.logger.info( + { current: result.currentVersion, latest: result.latestVersion }, + 'update available', + ); + } + } catch (err) { + // 兜底:checkForUpdate 约定不抛;万一抛了也吞掉,绝不冒泡成任何用户可见行为。 + this.logger.debug({ err }, 'update check threw (silent, no prompt)'); + } + } +} diff --git a/apps/desktop/src/main/bootstrap/window-manager.ts b/apps/desktop/src/main/bootstrap/window-manager.ts new file mode 100644 index 00000000..788486d3 --- /dev/null +++ b/apps/desktop/src/main/bootstrap/window-manager.ts @@ -0,0 +1,111 @@ +import { app, BrowserWindow, shell } from 'electron'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { Logger } from 'pino'; +import { readWindowState, writeWindowState, type WindowState } from '../utils/window-state.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * 主窗口管理:建窗(恢复尺寸 + 自绘标题栏 + 外链路由 + 首帧显示/关 splash)+ resize/move/close 防抖回写。 + * windowState 是实例可变状态(跨多次 create——如 macOS activate 重建——保持最新),故以 class 封装; + * 异步载入由 loadWindowManager 完成(构造同步、不持异步)。 + */ +export class WindowManager { + constructor( + private readonly stateStore: JsonFileStateStore, + private readonly logger: Logger, + /** 进程启动时刻,用于度量到首帧(ready-to-show)的启动耗时。 */ + private readonly startMs: number, + /** 当前窗口状态(尺寸/最大化);随 resize/move/close 回写。 */ + private windowState: WindowState, + ) {} + + /** 创建主窗口(首个传 splash,主界面首帧就绪时关闭它;macOS activate 再建时不传)。 */ + create(splash?: BrowserWindow): void { + // 最小尺寸保证核心三栏(sidebar 240 + file-tree 180 + diff 内容)在 chat-pane 折叠态下仍可用; + // 高度兜住 pr-header + tabs + diff + statusbar。尺寸优先用本地记录,无记录回退默认 1280×800。 + const win = new BrowserWindow({ + width: this.windowState.width ?? 1280, + height: this.windowState.height ?? 800, + minWidth: 960, + minHeight: 600, + show: false, + // 首帧前的窗口底色与 app 一致,避免显示瞬间白闪 + backgroundColor: '#1e1e1e', + // 无边框 + 自绘标题栏(VS Code 风):macOS 保留红绿灯并下移到自绘标题栏内;Windows/Linux 用 + // titleBarOverlay 让系统继续画窗控按钮,渲染层只接管中间标题区。高度需与 .app-titlebar 一致(36px)。 + titleBarStyle: 'hidden', + ...(process.platform === 'darwin' + ? { trafficLightPosition: { x: 12, y: 11 } } + : { titleBarOverlay: { color: '#1e1e1e', symbolColor: '#cccccc', height: 36 } }), + // dev 下显式给窗口图标;打包态窗口/任务栏图标走 exe 内嵌(electron-builder),故仅 dev 设置。 + icon: app.isPackaged + ? undefined + : path.join(app.getAppPath(), '../../assets/icons/icon.ico'), + webPreferences: { + preload: path.join(__dirname, '../preload/index.mjs'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + // 记住窗口大小:resize/move 防抖回写、关闭时立即回写。getNormalBounds 取「非最大化」尺寸, + // 故最大化时记录的仍是还原后的正常大小。写盘失败不影响使用。 + const persist = (): void => { + if (win.isDestroyed()) return; + const b = win.getNormalBounds(); + this.windowState = { width: b.width, height: b.height, maximized: win.isMaximized() }; + void writeWindowState(this.stateStore, this.windowState).catch((err: unknown) => { + this.logger.warn({ err }, 'persist window state failed'); + }); + }; + let saveTimer: ReturnType | undefined; + const scheduleSave = (): void => { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(persist, 400); + }; + win.on('resize', scheduleSave); + win.on('move', scheduleSave); + win.on('close', persist); + + // 主界面首帧就绪:恢复最大化态 → 显示主窗口 → 关闭 splash,并记录进程启动→首帧耗时。 + // maximize 必须放到这里:建窗后即调用会让无边框窗口在内容就绪前以空白态抢先出现(盖过/早于 splash)。 + win.once('ready-to-show', () => { + if (this.windowState.maximized) win.maximize(); + win.show(); + if (splash && !splash.isDestroyed()) splash.close(); + this.logger.info( + { elapsedMs: Date.now() - this.startMs }, + 'main window first paint (ready-to-show)', + ); + }); + + // 把
/ window.open 都路由到 OS 默认浏览器,不在 Electron 内开新窗口。 + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: 'deny' }; + }); + + if (process.env.ELECTRON_RENDERER_URL) { + void win.loadURL(process.env.ELECTRON_RENDERER_URL); + } else { + void win.loadFile(path.join(__dirname, '../renderer/index.html')); + } + } +} + +/** 载入窗口状态(缺失/损坏 → 空对象,回退默认尺寸)并构造 WindowManager。 */ +export async function loadWindowManager(deps: { + stateStore: JsonFileStateStore; + logger: Logger; + startMs: number; +}): Promise { + const windowState: WindowState = await readWindowState(deps.stateStore).catch((err: unknown) => { + deps.logger.warn({ err }, 'read window state failed; use default window size'); + return {}; + }); + return new WindowManager(deps.stateStore, deps.logger, deps.startMs, windowState); +} diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts new file mode 100644 index 00000000..52e9aa53 --- /dev/null +++ b/apps/desktop/src/main/controllers/agent.ts @@ -0,0 +1,184 @@ +import { loadAgentRules } from '@meebox/agent'; +import { + clearAgentSession, + clearAutopilotLedger, + clearReviewRunsForPr, + deleteReviewRun, + getAgentConversation, + getAgentSession, + getAgentTranscript, + getAutopilotLedger, + getReviewRun, + listReviewRunsForPr, +} from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import { AppError, ERROR_CODES, type AgentRecommendationVerdict } from '@meebox/shared'; +import { getContext } from '../services/context.js'; +import type { IpcController } from './types.js'; + +/* + * Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列 + */ + +/** + * 查 PR 当前命中的规则(ask 工具不接规则;无命中回 null)。 + */ +export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (_event, req) => { + if (req.tool === 'ask') return null; + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const rules = await loadAgentRules(ctx.effectiveAgentDir(), { + onWarn: (msg, file) => ctx.logger.warn({ file }, `rules: ${msg}`), + }); + const matched = pickMatchingRule(rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: req.tool, + }); + if (!matched) return null; + return { + id: matched.id, + filePath: matched.filePath, + priority: matched.priority, + tools: [...matched.tools], + instructions: matched.instructions, + }; +}; + +/** + * 评审微流程(describe→review→条件追问→总结),收尾落「评审总结」。 + */ +export const runReview: IpcController<'agent:run'> = async (_event, req) => { + const ctx = getContext(); + return ctx.orchestrator.runReview(await ctx.pr.findPrOrThrow(req.localId)); +}; + +/** + * 自由规划 Agent(自然语言「对话即委派」)。 + */ +export const runPlanning: IpcController<'agent:ask'> = async (_event, req) => { + const ctx = getContext(); + return ctx.orchestrator.runPlanning( + await ctx.pr.findPrOrThrow(req.localId), + req.question, + req.referencedContext, + ); +}; + +/** + * 运行期间追加一条用户消息:有 Agent 在跑则入队(下一周期并入重排),否则起一轮自由规划兜底。 + */ +export const enqueueMessage: IpcController<'agent:enqueueMessage'> = async (_event, req) => { + const ctx = getContext(); + return ctx.orchestrator.enqueueMessage(await ctx.pr.findPrOrThrow(req.localId), req.message); +}; + +/** + * 暂停某 PR 的 Agent 运行(思考 / 执行任意阶段即时中止)。 + */ +export const stopAgent: IpcController<'agent:stop'> = (_event, req) => + getContext().orchestrator.stop(req.localId); + +/** + * 读指定 PR 已落盘的 Agent 会话(跨 PR 切换、重启后恢复)。 + */ +export const getSession: IpcController<'agent:getSession'> = (_event, req) => + getAgentSession(getContext().stateStore, req.localId); + +/** + * 读指定 PR 的多轮对话消息。 + */ +export const getConversation: IpcController<'agent:getConversation'> = (_event, req) => + getAgentConversation(getContext().stateStore, req.localId); + +/** + * 读指定 PR 的 Agent 过程步骤(transcript)。 + */ +export const getTranscript: IpcController<'agent:getTranscript'> = (_event, req) => + getAgentTranscript(getContext().stateStore, req.localId); + +/** + * 批量读 AutoPilot 台账:仅返回 decision=review 且有建议者的 recommendation(PR 列表徽标用)。 + */ +export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = async (_event, req) => { + const { stateStore } = getContext(); + const out: Record = {}; + for (const id of req.localIds) { + const ledger = await getAutopilotLedger(stateStore, id); + if (ledger?.decision === 'review' && ledger.recommendation) { + out[id] = ledger.recommendation; + } + } + return out; +}; + +/* + * pr-agent run 队列(评审工具执行层;agent:run / AutoPilot 与用户手动 run 共用同一队列) + */ + +/** + * 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 + */ +export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { + const ctx = getContext(); + if (!ctx.getPrAgentBridge()) { + throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); + } + if (req.tool === 'ask' && !req.question?.trim()) { + throw new AppError(ERROR_CODES.AG_ASK_NEEDS_QUESTION); + } + const pr = await ctx.pr.findPrOrThrow(req.localId); + return ctx.runQueue.enqueuePragentRun( + pr, + req.tool, + req.question, + 'user', + req.referencedContext, + req.referencedFinding, + ); +}; + +/** + * 取消一个 run(active SIGKILL / waiting 出队)。 + */ +export const cancelPragent: IpcController<'pragent:cancel'> = (_event, req) => + getContext().runQueue.cancel(req.runId); + +/** + * 当前队列快照(启动 / 重连兜底)。 + */ +export const getQueue: IpcController<'pragent:queue'> = () => getContext().runQueue.snapshot(); + +/** + * 列某 PR 历史 run(游标分页)。 + */ +export const listRuns: IpcController<'pragent:listRuns'> = (_event, req) => + listReviewRunsForPr(getContext().stateStore, req.localId, { + limit: req.limit, + beforeId: req.beforeId, + }); + +/** + * 单条 run 查询。 + */ +export const getRun: IpcController<'pragent:getRun'> = (_event, req) => + getReviewRun(getContext().stateStore, req.localId, req.runId); + +/** + * 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 + */ +export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) => { + const ctx = getContext(); + await clearAgentSession(ctx.stateStore, req.localId); + await clearAutopilotLedger(ctx.stateStore, req.localId); + ctx.broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); + return { cleared: await clearReviewRunsForPr(ctx.stateStore, req.localId) }; +}; + +/** + * 删除单条 run 记录(仅该 run,不动 Agent 会话 / 台账 / ★ 徽标)。renderer 乐观从列表移除。 + */ +export const deleteRun: IpcController<'pragent:deleteRun'> = async (_event, req) => { + return { ok: await deleteReviewRun(getContext().stateStore, req.localId, req.runId) }; +}; diff --git a/apps/desktop/src/main/controllers/app.ts b/apps/desktop/src/main/controllers/app.ts new file mode 100644 index 00000000..56d0482f --- /dev/null +++ b/apps/desktop/src/main/controllers/app.ts @@ -0,0 +1,211 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { app, BrowserWindow, dialog, shell } from 'electron'; +import type { Logger } from 'pino'; +import { buildAppInfo, buildConnectionSummaries } from '../services/app.js'; +import { getContext } from '../services/context.js'; +import { sniffImageContentType } from '../utils/image.js'; +import { checkForUpdate } from '../utils/update-check.js'; +import { getLastUpdateResult, publishUpdateResult } from '../utils/update-state.js'; +import type { IpcController } from './types.js'; + +/* + * GUI 框架交互域 controllers:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 + */ + +/** + * 应用 / 运行时版本信息(关于页)。 + */ +export const readAppInfo: IpcController<'app:info'> = () => buildAppInfo(getContext().bootstrap); + +/** + * 关键目录路径(config / agent / 日志)。 + */ +export const readAppPaths: IpcController<'app:paths'> = () => getContext().bootstrap.paths; + +/** + * pr-agent 探测状态(是否就绪)。 + */ +export const readPrAgentStatus: IpcController<'app:prAgentStatus'> = () => + getContext().getPrAgentStatus(); + +let rendererLogger: Logger | undefined; +/** + * 渲染层错误 / 未捕获异常转发到 main,按级别写 renderer scope 日志(落同一份 meebox.log)。 + */ +export const writeRendererLog: IpcController<'log:write'> = (_event, req) => { + rendererLogger ??= getContext().logger.child({ scope: 'renderer' }); + const obj = req.meta ?? {}; + switch (req.level) { + case 'error': + rendererLogger.error(obj, req.msg); + break; + case 'warn': + rendererLogger.warn(obj, req.msg); + break; + case 'info': + rendererLogger.info(obj, req.msg); + break; + case 'debug': + rendererLogger.debug(obj, req.msg); + break; + } +}; + +/** + * 各连接 ping 后缓存(当前用户 + display_name),Header / 状态栏用。 + */ +export const listConnections: IpcController<'app:connections'> = () => { + const { bootstrap, connectionRuntime } = getContext(); + return buildConnectionSummaries(bootstrap, connectionRuntime.adapters); +}; + +// 头像两级缓存:进程内 Map(含 null 负缓存)+ 磁盘文件(TTL 7 天,按 mtime 判过期)。 +const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const avatarMem = new Map(); + +/** + * 按 (connectionId, slug) 拉头像 dataUrl:内存 → 磁盘 → 远端,失败回 null。 + */ +export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req) => { + const { logger, connectionRuntime, bootstrap } = getContext(); + const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); + const memKey = `${req.connectionId}|${req.slug}`; + if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; + + const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); + const filePath = path.join(avatarDir, `${hash}.bin`); + + // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) + try { + const stat = await fs.stat(filePath); + const age = Date.now() - stat.mtimeMs; + if (age < AVATAR_TTL_MS) { + const bytes = await fs.readFile(filePath); + const contentType = sniffImageContentType(bytes); + const result = { dataUrl: `data:${contentType};base64,${bytes.toString('base64')}` }; + avatarMem.set(memKey, result); + return result; + } + // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) + await fs.unlink(filePath).catch(() => undefined); + } catch { + // 文件不存在 / 读失败 → 走 fetch + } + + // 2) 没缓存 / 已过期:去远端拉 + const adapter = connectionRuntime.adapters.find( + (a) => a.connectionId === req.connectionId, + )?.adapter; + if (!adapter) { + avatarMem.set(memKey, null); + return null; + } + try { + const img = await adapter.getUserAvatar(req.slug, req.avatarUrl); + if (!img) { + logger.debug({ connectionId: req.connectionId, slug: req.slug }, 'avatar fetch returned null'); + avatarMem.set(memKey, null); + return null; + } + // 落盘:best-effort,写失败不影响响应 + try { + await fs.mkdir(avatarDir, { recursive: true }); + await fs.writeFile(filePath, img.bytes); + } catch (writeErr) { + logger.warn({ err: writeErr, hash }, 'avatar disk write failed'); + } + const base64 = Buffer.from(img.bytes).toString('base64'); + const result = { dataUrl: `data:${img.contentType};base64,${base64}` }; + avatarMem.set(memKey, result); + logger.debug( + { hash, slug: req.slug, bytes: img.bytes.length, contentType: img.contentType }, + 'avatar fetched + cached to disk', + ); + return result; + } catch (err) { + logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw'); + avatarMem.set(memKey, null); + return null; + } +}; + +/** + * OS 默认编辑器打开 config.yaml。 + */ +export const openConfigFile: IpcController<'app:openConfigFile'> = async () => { + const err = await shell.openPath(getContext().bootstrap.paths.configFile); + if (err) throw new Error(`failed to open config.yaml: ${err}`); +}; + +/** + * 文件管理器打开当前生效的 Agent 目录(不存在则先建)。 + */ +export const openAgentDir: IpcController<'app:openAgentDir'> = async () => { + const dir = getContext().effectiveAgentDir(); + await fs.mkdir(dir, { recursive: true }).catch(() => undefined); + const err = await shell.openPath(dir); + if (err) throw new Error(`failed to open agent dir: ${err}`); +}; + +/** + * 打开 DevTools(分离窗口)——需访问发起调用的 webContents。 + */ +export const openDevTools: IpcController<'app:openDevTools'> = (event) => { + event.sender.openDevTools({ mode: 'detach' }); +}; + +/** + * 手动检测更新:受 check_enabled 门控;结果交单一真相源缓存 + 有新版广播。 + */ +export const checkUpdate: IpcController<'app:checkUpdate'> = async () => { + const { bootstrap } = getContext(); + if (!bootstrap.config.update.check_enabled) { + return { + ok: false, + hasUpdate: false, + currentVersion: app.getVersion(), + error: 'update check disabled by config', + }; + } + const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy); + publishUpdateResult(result); + return result; +}; + +/** + * 读 main 缓存的最近一次更新检测结果(不发请求)。 + */ +export const getUpdateStatus: IpcController<'app:getUpdateStatus'> = () => getLastUpdateResult(); + +/** + * 系统浏览器打开外链(白名单仅放行 http(s),防 file:// / javascript: 注入)。 + */ +export const openExternal: IpcController<'app:openExternal'> = async (_event, req) => { + if (!/^https?:\/\//.test(req.url)) return; + await shell.openExternal(req.url); +}; + +/** + * 系统原生目录选择对话框——需绑定到发起调用的窗口。 + */ +export const pickDirectory: IpcController<'dialog:pickDirectory'> = async (event, req) => { + const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; + // 标题由前端按 UI 语言提供(目录选择属交互领域文案,统一在渲染层 i18n 维护,主进程不再 localize)。 + const result = win + ? await dialog.showOpenDialog(win, { + title: req.title, + defaultPath: req.defaultPath, + properties: ['openDirectory', 'createDirectory'], + }) + : await dialog.showOpenDialog({ + title: req.title, + defaultPath: req.defaultPath, + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) { + return { path: null }; + } + return { path: result.filePaths[0]! }; +}; diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts new file mode 100644 index 00000000..4c04c2fd --- /dev/null +++ b/apps/desktop/src/main/controllers/config.ts @@ -0,0 +1,178 @@ +import { writeConfig } from '@meebox/config'; +import { buildDraftAdapter } from '../adapters.js'; +import { setMainLanguage } from '../i18n/index.js'; +import { getContext } from '../services/context.js'; +import { testProxyConnectivity } from '../utils/proxy.js'; +import type { IpcController } from './types.js'; + +/* + * 配置操作域 controllers:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连 + */ + +/** + * 读当前内存配置。 + */ +export const readConfig: IpcController<'config:read'> = () => getContext().bootstrap.config; + +/** + * 写 repos_dir(重启生效)。 + */ +export const setReposDir: IpcController<'config:setReposDir'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { + ...bootstrap.config, + workspace: { ...bootstrap.config.workspace, repos_dir: req.reposDir }, + }; + await writeConfig(bootstrap.paths.configFile, next); + logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); +}; + +/** + * 写 UI 语言并即时生效:内存同步 + 主进程 i18n changeLanguage。 + */ +export const setLanguage: IpcController<'config:setLanguage'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { ...bootstrap.config, language: req.language }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.language = req.language; + setMainLanguage(req.language); + logger.info({ language: req.language }, 'language config updated'); +}; + +/** + * 写 LLM Provider 配置;内存同步,下次 pragent:run 用新值。 + */ +export const setLlm: IpcController<'config:setLlm'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { ...bootstrap.config, llm: req.llm }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.llm = req.llm; + logger.info( + { profileCount: req.llm.profiles.length, activeId: req.llm.active_id }, + 'llm config updated', + ); +}; + +/** + * 写 agent 配置(含 agent.dir);内存同步,下次 pragent:run 现读生效。 + */ +export const setAgent: IpcController<'config:setAgent'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { ...bootstrap.config, agent: req.agent }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.agent = req.agent; + logger.info({ agent: req.agent }, 'agent config updated'); +}; + +/** + * 翻转 AutoPilot 开关;关→开时立即 poll 一轮按准入规则评估。 + */ +export const setAutopilotEnabled: IpcController<'agent:setAutopilotEnabled'> = async ( + _event, + req, +) => { + const { bootstrap, logger, poller } = getContext(); + const was = bootstrap.config.agent.autopilot.enabled; + const agent = { + ...bootstrap.config.agent, + autopilot: { ...bootstrap.config.agent.autopilot, enabled: req.enabled }, + }; + await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, agent }); + bootstrap.config.agent = agent; + logger.info({ enabled: req.enabled }, 'autopilot toggled'); + if (req.enabled && !was) { + void poller.tick(); + } +}; + +/** + * 写连接列表 + 启用连接,热重建 adapter/poller 并立即 poll 一轮。 + */ +export const setConnections: IpcController<'config:setConnections'> = async (_event, req) => { + const { bootstrap, logger, poller, reconfigureConnections } = getContext(); + const next = { + ...bootstrap.config, + connections: req.connections, + active_connection_id: req.active_connection_id, + }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.connections = req.connections; + bootstrap.config.active_connection_id = req.active_connection_id; + await reconfigureConnections(); + void poller.tick(); + logger.info( + { count: req.connections.length, activeId: req.active_connection_id }, + 'connections config updated (hot-reloaded)', + ); +}; + +/** + * 写代理配置,热重建 adapter(REST 经代理即时生效)。 + */ +export const setProxy: IpcController<'config:setProxy'> = async (_event, req) => { + const { bootstrap, logger, reconfigureConnections } = getContext(); + const next = { ...bootstrap.config, proxy: req.proxy }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.proxy = req.proxy; + await reconfigureConnections(); + logger.info( + { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, + 'proxy config updated (hot-reloaded)', + ); +}; + +/** + * 用给定代理试连,验证可用性;不写配置。 + */ +export const testProxy: IpcController<'config:testProxy'> = (_event, req) => + testProxyConnectivity(req.proxy); + +/** + * 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason。 + */ +export const testConnection: IpcController<'config:testConnection'> = async (_event, req) => { + try { + return await buildDraftAdapter( + req.base_url, + req.token, + getContext().bootstrap.config.proxy, + req.kind, + ).ping(); + } catch (e) { + return { ok: false, reason: e instanceof Error ? e.message : String(e) }; + } +}; + +/** + * 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 + */ +export const autosaveDraft: IpcController<'config:autosaveDraft'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { + ...bootstrap.config, + connections: req.connections, + active_connection_id: req.active_connection_id, + llm: req.llm, + }; + await writeConfig(bootstrap.paths.configFile, next); + logger.info( + { connections: req.connections.length, profiles: req.llm.profiles.length }, + 'connections/llm draft autosaved to config.yaml (not applied)', + ); +}; + +/** + * 写轮询间隔(clamp 60~900)并热替换 poller 定时器,无需重启。 + */ +export const setPoller: IpcController<'config:setPoller'> = async (_event, req) => { + const { bootstrap, logger, poller } = getContext(); + const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); + const next = { + ...bootstrap.config, + poller: { ...bootstrap.config.poller, interval_seconds: seconds }, + }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.poller.interval_seconds = seconds; + poller.setIntervalSeconds(seconds); + logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); +}; diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts new file mode 100644 index 00000000..86c23f0d --- /dev/null +++ b/apps/desktop/src/main/controllers/pr.ts @@ -0,0 +1,499 @@ +import { + addFindingClosure, + createDraft, + deleteDraft, + isCommentsCacheStale, + listDrafts, + listFindingClosures, + listStoredPullRequests, + readCommentsCache, + removeFindingClosure, + setLocalStatus, + updateDraft, + writeCommentsCache, +} from '@meebox/poller'; +import type { RepoIdentity } from '@meebox/repo-mirror'; +import { ERROR_CODES, errorCodeMessage, type PrComment } from '@meebox/shared'; +import { annotateOwnership } from '../services/comments.js'; +import { getContext } from '../services/context.js'; +import type { IpcController } from './types.js'; + +/* + * PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 + */ + +/** + * 对已有评论发回复,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 + */ +export const replyComment: IpcController<'comments:reply'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const reply = await adapter.replyToComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.parentCommentId, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return reply; +}; + +/** + * 在 PR 上新建一条 summary(顶层、不锚文件)评论,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 + */ +export const createComment: IpcController<'comments:create'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const created = await adapter.publishSummaryComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return created; +}; + +/** + * 删除自己作者的远端评论(带 version 乐观锁)。失败原文抛给 renderer;成功后清缓存 + 广播。 + */ +export const deleteComment: IpcController<'comments:delete'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + await adapter.deleteComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.commentId, + req.version, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); +}; + +/** + * 编辑自己作者评论 body(带 version 乐观锁)。返回 updated 仅作乐观参考;清缓存 + 广播。 + */ +export const editComment: IpcController<'comments:edit'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const updated = await adapter.editComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.commentId, + req.version, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return updated; +}; + +/** + * 拉评论内嵌图片(私有实例需带 PAT,renderer 无法直接 fetch)→ 经 main 代理回 dataUrl。不缓存。 + */ +export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async (_event, req) => { + try { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterFor(pr); + if (!adapter) return null; + // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL + const res = await adapter.getAttachment(req.url, pr.repo); + if (!res) return null; + const base64 = Buffer.from(res.bytes).toString('base64'); + return { dataUrl: `data:${res.contentType};base64,${base64}` }; + } catch { + return null; + } +}; + +/** + * 只展示当前活动连接的 PR(状态库可能仍存切换前其他连接的历史 PR)。 + */ +export const listPrs: IpcController<'prs:list'> = async () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const all = await listStoredPullRequests(ctx.stateStore); + return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; +}; + +/** + * 立即跑一轮 poll。 + */ +export const refreshPrs: IpcController<'prs:refresh'> = () => getContext().poller.tick(); + +/** + * Poller 最近一次完成时间(启动初始化用)。 + */ +export const getLastSync: IpcController<'prs:lastSync'> = () => ({ + at: getContext().poller.getLastPollAt(), +}); + +/** + * 设审阅状态:先写远端(失败前端不变),远端 OK 后落本地。 + */ +export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const remoteStatus = + req.status === 'approved' + ? 'approved' + : req.status === 'needs_work' + ? 'needsWork' + : 'unapproved'; + await adapter.setPullRequestReviewStatus( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + remoteStatus, + ); + return setLocalStatus(ctx.stateStore, req.localId, req.status); +}; + +/** + * 合并 PR;不在此落本地,靠 renderer refresh → poll 软删收尾,避免本地与远端各执一词。 + */ +export const mergePr: IpcController<'prs:merge'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + await adapter.mergePullRequest( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); +}; + +/** + * 确保 PR 所属 repo 镜像就位(快速路径命中即 noop)。 + */ +export const syncRepo: IpcController<'repo:sync'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + return ctx.pr.ensureMirrorReadyForPr(pr); +}; + +/** + * 列出变更文件(先确保镜像)。默认 PR merge-base..head 全部变更;传 base/head 则列该范围 + * (如某 commit 的 parent..sha),用于「查看特定 commit」。 + */ +export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + await ctx.pr.ensureMirrorReadyForPr(pr); + const base = req.base ?? (await ctx.pr.resolveDiffBaseSha(pr)); + const head = req.head ?? pr.sourceRef.sha; + return ctx.repoMirror.listChangedFiles(id, base, head); +}; + +/** + * 列出合并会冲突的文件(文件树据此标三角警示)。仅当远端判定 PR 有冲突(pr.hasConflict)才实际跑 + * 本地 merge-tree 试合并——目标分支 tip ⟂ 源 head;无冲突的 PR 直接返回空,省一次试合并。 + */ +export const listConflictFiles: IpcController<'diff:listConflictFiles'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + if (!pr.hasConflict) return []; + const id = ctx.pr.repoIdentityFor(pr); + await ctx.pr.ensureMirrorReadyForPr(pr); + return ctx.repoMirror.listConflictFiles(id, pr.targetRef.sha, pr.sourceRef.sha); +}; + +/** + * 读 base / head 一侧文件内容。默认 PR merge-base / head;传 base/head 则按指定范围 + * (commit 视图:base=parent、head=commit)。 + */ +export const getFileContent: IpcController<'diff:getFileContent'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + const sha = + req.side === 'base' + ? (req.base ?? (await ctx.pr.resolveDiffBaseSha(pr))) + : (req.head ?? pr.sourceRef.sha); + return ctx.repoMirror.getFileContent(id, sha, req.path); +}; + +/** + * 仅读评论缓存条数(tab 角标懒展示),不打远端。 + */ +export const getCommentCountCached: IpcController<'diff:commentCountCached'> = async ( + _event, + req, +) => { + const cache = await readCommentsCache(getContext().stateStore, req.localId); + if (!cache) return null; + return { count: cache.comments.length }; +}; + +// In-flight dedup: 打开 PR 时多个组件并行调 listComments(force:true),合并到同一 Promise,远端只打一次。 +const listCommentsInFlight = new Map>(); + +/** + * 拉评论:cache + pr_updated_at stale 比对;force=true 跳缓存。同 localId in-flight 去重。 + */ +export const listComments: IpcController<'diff:listComments'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const cache = await readCommentsCache(ctx.stateStore, pr.localId); + if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { + return cache.comments; + } + const existing = listCommentsInFlight.get(pr.localId); + if (existing) return existing; + const adapter = ctx.pr.adapterForOrThrow(pr); + // dedup 要求把 in-flight Promise **同步**存进 map 后再 await:故显式构造 Promise(内部用 async + // IIFE 顺序 await)并 set,再 return。不能整体写成顶层 async 函数体内直接 await——首个 await 挂起前 + // Promise 还没注册进 map,落在这窗口内的并发请求就会各自再打一次远端。.finally 绑在 Promise 上做 + // 清理(与具体 await 方无关,成功 / 失败都摘除 map 项)。 + const fetchPromise = (async () => { + const raw = await adapter.listPullRequestComments( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); + const fresh = annotateOwnership(raw, adapter); + await writeCommentsCache(ctx.stateStore, pr.localId, { + comments: fresh, + pr_updated_at: pr.updatedAt, + fetched_at: new Date().toISOString(), + }); + return fresh; + })().finally(() => { + listCommentsInFlight.delete(pr.localId); + }); + listCommentsInFlight.set(pr.localId, fetchPromise); + return fetchPromise; +}; + +/** + * 拉 commits(不缓存,量少 + 进 commits 标签页 / 活动时间线才拉)。 + * + * 平台 `/commits` 端点返回 `target..source` 全集——长期分支 / fork 同步分支历史上反复把别的分支 + * merge 进源分支,会把大量 merge 提交与合入的他人提交一并带出,淹没本 PR 真正引入的提交。这里用本地 + * 镜像按 first-parent 主干算出「本 PR 自产提交」SHA 集合做交集过滤(与提交数角标同口径,见 + * {@link RepoMirrorManager.listIntroducedCommitShas})。镜像未就位 / 算不出 → 退回未过滤的平台列表, + * 至少不丢信息。 + */ +export const listCommits: IpcController<'diff:listCommits'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const remote = await adapter.listPullRequestCommits( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); + try { + const id = ctx.pr.repoIdentityFor(pr); + await ctx.pr.ensureMirrorReadyForPr(pr); + const base = await ctx.pr.resolveDiffBaseSha(pr); + const introduced = await ctx.repoMirror.listIntroducedCommitShas(id, base, pr.sourceRef.sha); + if (introduced === null) return remote; + const keep = new Set(introduced); + return remote.filter((c) => keep.has(c.sha)); + } catch (err) { + ctx.logger.warn( + { err, localId: pr.localId }, + 'listCommits: first-parent filter failed; returning unfiltered platform list', + ); + return remote; + } +}; + +/** + * 拉评审决断活动事件(approve / needs-work / unapprove / dismiss)。不缓存,量小; + * 进活动时间线时与评论 / 提交归并。平台取不到历史决断时 adapter 返回 []。 + */ +export const listActivity: IpcController<'diff:listActivity'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + return adapter.listPullRequestActivity( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); +}; + +/** + * 本地 git 算 PR 引入提交数(base=merge-base,first-parent 主干口径,与 listCommits 过滤集一致, + * 排除合入的目标提交与历史 merge 带进的他人提交);镜像未齐返回 null。 + */ +export const getCommitCount: IpcController<'diff:commitCount'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + const base = await ctx.pr.resolveDiffBaseSha(pr); + const n = await ctx.repoMirror.countCommits(id, base, pr.sourceRef.sha); + return n === null ? null : { count: n }; +}; + +/** + * head 侧 blame;PR 引入行单独返回供 BlameColumn 画色带占位。 + */ +export const getBlame: IpcController<'diff:getBlame'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + const base = req.base ?? (await ctx.pr.resolveDiffBaseSha(pr)); + const head = req.head ?? pr.sourceRef.sha; + const [allBlame, changedSet] = await Promise.all([ + ctx.repoMirror.getBlame(id, head, req.path), + ctx.repoMirror.listChangedHeadLines(id, base, head, req.path), + ]); + return { + lines: allBlame.filter((b) => !changedSet.has(b.line)), + changedLines: Array.from(changedSet).sort((a, b) => a - b), + }; +}; + +/** + * 本地所有 repo 镜像总占用字节数(按 host|projectKey|repoSlug 去重)。 + */ +export const getTotalSize: IpcController<'repo:getTotalSize'> = async () => { + const ctx = getContext(); + const prs = await listStoredPullRequests(ctx.stateStore); + const seen = new Set(); + let total = 0; + for (const pr of prs) { + let id: RepoIdentity; + try { + id = ctx.pr.repoIdentityFor(pr); + } catch { + continue; + } + const key = `${id.host}|${id.projectKey}|${id.repoSlug}`; + if (seen.has(key)) continue; + seen.add(key); + const r = await ctx.repoMirror.getSize(id); + total += r.totalBytes; + } + return { totalBytes: total }; +}; + +/** + * 列某 PR 全部草稿。 + */ +export const getDrafts: IpcController<'drafts:list'> = (_event, req) => + listDrafts(getContext().stateStore, req.localId); + +/** + * 创建草稿;IPC 边界再挡一道 origin/source 约束避免脏数据进盘。 + */ +export const addDraft: IpcController<'drafts:create'> = async (_event, req) => { + const ctx = getContext(); + const { draft, localId } = req; + if (draft.origin === 'finding' && !draft.source) { + throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); + } + if (draft.origin === 'manual' && draft.source) { + throw new Error('drafts:create: origin=manual 不应该传 source'); + } + const created = await createDraft(ctx.stateStore, localId, draft); + ctx.broadcast('drafts:changed', { localId }); + return created; +}; + +/** + * 部分更新草稿(pending 编辑 body 自动转 edited;找不到返回 null)。 + */ +export const patchDraft: IpcController<'drafts:update'> = async (_event, req) => { + const ctx = getContext(); + const updated = await updateDraft(ctx.stateStore, req.localId, req.draftId, req.patch); + if (updated) ctx.broadcast('drafts:changed', { localId: req.localId }); + return updated; +}; + +/** + * 删除草稿。 + */ +export const removeDraft: IpcController<'drafts:delete'> = async (_event, req) => { + const ctx = getContext(); + await deleteDraft(ctx.stateStore, req.localId, req.draftId); + ctx.broadcast('drafts:changed', { localId: req.localId }); +}; + +/** finding 关闭关系:列出本 PR 全部(复评 /ask 取代/撤销原 finding 的关闭记录)。 */ +export const getFindingClosures: IpcController<'findingClosures:list'> = (_event, req) => + listFindingClosures(getContext().stateStore, req.localId); + +/** 记一条关闭关系(复评卡片的「采纳并关闭原 / 关闭原」动作);广播让 finding 卡片重拉换关闭态。 */ +export const addClosure: IpcController<'findingClosures:create'> = async (_event, req) => { + const ctx = getContext(); + const created = await addFindingClosure(ctx.stateStore, req.localId, { + runId: req.runId, + findingId: req.findingId, + byAskRunId: req.byAskRunId, + verdict: req.verdict, + }); + ctx.broadcast('findingClosures:changed', { localId: req.localId }); + return created; +}; + +/** 撤销关闭(finding 卡片的「撤销关闭」动作)。 */ +export const removeClosure: IpcController<'findingClosures:delete'> = async (_event, req) => { + const ctx = getContext(); + await removeFindingClosure(ctx.stateStore, req.localId, req.runId, req.findingId); + ctx.broadcast('findingClosures:changed', { localId: req.localId }); +}; + +/** + * 批量发布草稿:逐条 publishInlineComment,单条失败不中断;成功即删本地草稿。 + * 整批跑完广播 drafts:changed;有任一成功则 force-refresh 评论 + 广播 comments:changed。 + */ +export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + + // 拉一次当前草稿池:localId → id → draft,避免循环里反复 listDrafts 的 O(N²) IO + const allDrafts = await listDrafts(ctx.stateStore, req.localId); + const draftById = new Map(allDrafts.map((d) => [d.id, d])); + + const results: { draftId: string; ok: boolean; postedRemoteId?: string; error?: string }[] = []; + let anyPublished = false; + for (const draftId of req.draftIds) { + const draft = draftById.get(draftId); + if (!draft) { + results.push({ draftId, ok: false, error: errorCodeMessage(ERROR_CODES.PR_DRAFT_NOT_FOUND) }); + continue; + } + // rejected 不发(用户决断不发)。posted 不守卫:发布成功即删本地草稿,不存历史 posted 态。 + if (draft.status === 'rejected') { + results.push({ draftId, ok: false, error: errorCodeMessage(ERROR_CODES.PR_DRAFT_REJECTED) }); + continue; + } + try { + // ReviewDraftAnchor → PrCommentAnchor:side 保守映射 new→added / old→removed; + // 多行落 endLine(评论出现在标注范围下方,不打断从上往下阅读)。命中 context 行 + // Bitbucket 回 400,错误收进 results 给用户看。 + const posted = await adapter.publishInlineComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + { + path: draft.anchor.path, + line: draft.anchor.endLine, + side: draft.anchor.side, + lineType: draft.anchor.side === 'old' ? 'removed' : 'added', + }, + draft.body, + ); + // 发布成功 = 本地草稿使命完成,直接删掉(远端评论由下面 force-refresh 拉回承接显示)。 + await deleteDraft(ctx.stateStore, req.localId, draftId); + anyPublished = true; + results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + ctx.logger.warn( + { localId: req.localId, draftId, err: msg }, + 'drafts:publishBatch: single draft failed', + ); + results.push({ draftId, ok: false, error: msg }); + } + } + + ctx.broadcast('drafts:changed', { localId: req.localId }); + if (anyPublished) { + await ctx.pr.invalidateCommentsCache(pr.localId); + } + return { results }; +}; diff --git a/apps/desktop/src/main/controllers/types.ts b/apps/desktop/src/main/controllers/types.ts new file mode 100644 index 00000000..b8d2303e --- /dev/null +++ b/apps/desktop/src/main/controllers/types.ts @@ -0,0 +1,17 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import type { IpcChannelName, IpcChannels } from '@meebox/ipc'; + +/** + * IPC controller 的类型:原生 `ipcMain.handle` 监听器形态 `(event, req)`,直接 + * `ipcMain.handle('channel', controller)` 注册、无包装层。通道字符串与 controller 的匹配由 + * ipcMain.handle 的宽松签名兜不住,靠注册处命名 + 注释保证。 + * + * @template K 通道名(约束 `extends IpcChannelName` 必需:req/response 由 `IpcChannels[K]` 索引取出)。 + * @param event electron IpcMainInvokeEvent;仅少数需窗口上下文的 controller 用(对话框 / DevTools),其余以 `_event` 占位。 + * @param req 该通道的强类型请求体。 + * @returns 该通道的 response(同步或异步)。 + */ +export type IpcController = ( + event: IpcMainInvokeEvent, + req: IpcChannels[K]['request'], +) => IpcChannels[K]['response'] | Promise; diff --git a/apps/desktop/src/main/i18n/locales/de-DE.json b/apps/desktop/src/main/i18n/locales/de-DE.json index 98168ae3..c321f76f 100644 --- a/apps/desktop/src/main/i18n/locales/de-DE.json +++ b/apps/desktop/src/main/i18n/locales/de-DE.json @@ -1,24 +1,23 @@ { - "dialog": { - "selectDirectory": "Verzeichnis auswählen" - }, - "drafts": { - "notFound": "Entwurf nicht gefunden (möglicherweise gelöscht)", - "rejected": "Entwurf wurde abgelehnt, wird übersprungen" - }, - "prAgent": { - "askNeedsQuestion": "/ask erfordert eine Frage", - "duplicateTask": "Eine /{{tool}}-Aufgabe für diesen PR wird bereits ausgeführt oder steht in der Warteschlange; bitte nicht erneut auslösen.", - "notReady": "pr-agent ist nicht bereit", - "notReadyDetail": "pr-agent ist nicht bereit: Weder die eingebettete Laufzeitumgebung noch eine lokale CLI wurde erkannt. Details siehe Erkennungsergebnisse unter Einstellungen." - }, - "proxy": { - "authFailed": "Proxy-Authentifizierung fehlgeschlagen (407); Benutzername/Passwort prüfen", - "disabled": "Proxy ist deaktiviert oder die Adresse ist leer" - }, - "update": { - "missingTag": "Im Release fehlt tag_name", - "parseCurrentFailed": "Aktuelle Version kann nicht ausgewertet werden: {{version}}", - "parseLatestFailed": "Neueste Version kann nicht ausgewertet werden: {{tag}}" + "agent": { + "steps": { + "describeReview": "PR-Beschreibung und Review-Befunde erstellen", + "improve": "Code-Verbesserungsvorschläge erstellen", + "judge": "Entscheiden, ob wichtige Probleme eine Rückfrage erfordern", + "judgeNone": "Keine wichtigen Probleme — keine Rückfrage", + "judgeSevere_one": "Wichtig — {{count}} Rückfrage", + "judgeSevere_other": "Wichtig — {{count}} Rückfragen", + "rejectedPrefix": "Abgelehnt: ", + "summary": "Beschreibung und Befunde zu einer Review-Zusammenfassung zusammenfassen" + }, + "summarySections": { + "findings": "Wichtige Erkenntnisse", + "overview": "Zusammenfassung", + "suggestions": "Empfehlungen" + }, + "termination": { + "aborted": "Vom Benutzer abgebrochen", + "maxSteps": "Schrittlimit erreicht" + } } } diff --git a/apps/desktop/src/main/i18n/locales/en-US.json b/apps/desktop/src/main/i18n/locales/en-US.json index e6e292d8..1e98f87b 100644 --- a/apps/desktop/src/main/i18n/locales/en-US.json +++ b/apps/desktop/src/main/i18n/locales/en-US.json @@ -1,24 +1,23 @@ { - "dialog": { - "selectDirectory": "Select directory" - }, - "drafts": { - "notFound": "Draft not found (it may have been deleted)", - "rejected": "Draft was rejected, skipping" - }, - "prAgent": { - "askNeedsQuestion": "/ask requires a question", - "duplicateTask": "A /{{tool}} task for this PR is already running or queued; do not trigger it again.", - "notReady": "pr-agent not ready", - "notReadyDetail": "pr-agent not ready: neither the embedded runtime nor a local CLI was detected. See probe details in Settings." - }, - "proxy": { - "authFailed": "Proxy authentication failed (407); check the username/password", - "disabled": "Proxy is disabled or its address is empty" - }, - "update": { - "missingTag": "Release is missing tag_name", - "parseCurrentFailed": "Unable to parse the current version: {{version}}", - "parseLatestFailed": "Unable to parse the latest version: {{tag}}" + "agent": { + "steps": { + "describeReview": "Generate the PR description and review findings", + "improve": "Generate code improvement suggestions", + "judge": "Decide whether there are important issues needing follow-up", + "judgeNone": "No important issues — no follow-up", + "judgeSevere_one": "Important — {{count}} follow-up question", + "judgeSevere_other": "Important — {{count}} follow-up questions", + "rejectedPrefix": "Rejected: ", + "summary": "Synthesize the description and findings into a review summary" + }, + "summarySections": { + "findings": "Key findings", + "overview": "Summary", + "suggestions": "Suggestions" + }, + "termination": { + "aborted": "Paused by user", + "maxSteps": "Step limit reached" + } } } diff --git a/apps/desktop/src/main/i18n/locales/ja-JP.json b/apps/desktop/src/main/i18n/locales/ja-JP.json index b0103bce..b655b7a3 100644 --- a/apps/desktop/src/main/i18n/locales/ja-JP.json +++ b/apps/desktop/src/main/i18n/locales/ja-JP.json @@ -1,24 +1,22 @@ { - "dialog": { - "selectDirectory": "ディレクトリを選択" - }, - "drafts": { - "notFound": "下書きが見つかりません(削除された可能性があります)", - "rejected": "下書きは却下されました。スキップします" - }, - "prAgent": { - "askNeedsQuestion": "/ask には質問の指定が必要です", - "duplicateTask": "この PR の /{{tool}} タスクはすでに実行中またはキュー待ちです。再度トリガーしないでください。", - "notReady": "pr-agent が準備できていません", - "notReadyDetail": "pr-agent が準備できていません:埋め込みランタイムもローカル CLI も検出されませんでした。詳細は設定の検出結果を参照してください。" - }, - "proxy": { - "authFailed": "プロキシ認証に失敗しました (407)。ユーザー名/パスワードを確認してください", - "disabled": "プロキシが無効か、アドレスが空です" - }, - "update": { - "missingTag": "リリースに tag_name がありません", - "parseCurrentFailed": "現在のバージョンを解析できません:{{version}}", - "parseLatestFailed": "最新バージョンを解析できません:{{tag}}" + "agent": { + "steps": { + "describeReview": "PR の説明とコードレビュー指摘を生成", + "improve": "コード改善提案を生成", + "judge": "追加質問が必要な重要な問題があるか判断", + "judgeNone": "重要な問題なし、追加質問なし", + "judgeSevere_other": "重要、追加質問 {{count}} 件", + "rejectedPrefix": "却下:", + "summary": "説明とレビュー指摘を統合してレビュー要約を生成" + }, + "summarySections": { + "findings": "主な指摘", + "overview": "概要", + "suggestions": "提案" + }, + "termination": { + "aborted": "ユーザーが中断しました", + "maxSteps": "ステップ上限に到達" + } } } diff --git a/apps/desktop/src/main/i18n/locales/zh-CN.json b/apps/desktop/src/main/i18n/locales/zh-CN.json index 2ed3f692..34ab3bd6 100644 --- a/apps/desktop/src/main/i18n/locales/zh-CN.json +++ b/apps/desktop/src/main/i18n/locales/zh-CN.json @@ -1,24 +1,22 @@ { - "dialog": { - "selectDirectory": "选择目录" - }, - "drafts": { - "notFound": "草稿不存在 (可能已被删除)", - "rejected": "草稿已被拒绝,跳过" - }, - "prAgent": { - "askNeedsQuestion": "/ask 需要提供 question", - "duplicateTask": "该 PR 的 /{{tool}} 任务已在执行或排队中,请勿重复触发", - "notReady": "pr-agent 未就绪", - "notReadyDetail": "pr-agent 未就绪:嵌入式运行时与本机 CLI 都未探测到。Settings 页查看探测细节" - }, - "proxy": { - "authFailed": "代理认证失败 (407),检查用户名/密码", - "disabled": "代理未启用或地址为空" - }, - "update": { - "missingTag": "Release 缺少 tag_name", - "parseCurrentFailed": "无法解析当前版本号:{{version}}", - "parseLatestFailed": "无法解析最新版本号:{{tag}}" + "agent": { + "steps": { + "describeReview": "生成 PR 描述与审查发现", + "improve": "生成代码改进建议", + "judge": "判断是否存在需追问的重要问题", + "judgeNone": "无重要问题,不追问", + "judgeSevere_other": "重要,追问 {{count}} 个", + "rejectedPrefix": "拒绝:", + "summary": "综合描述与审查发现,生成评审总结" + }, + "summarySections": { + "findings": "关键发现", + "overview": "摘要", + "suggestions": "建议" + }, + "termination": { + "aborted": "用户暂停", + "maxSteps": "步数上限中止" + } } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index be847e39..99f3382e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,639 +1,305 @@ -import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron'; +import { app, BrowserWindow, Menu, nativeTheme } from 'electron'; import path from 'node:path'; -import { readFileSync } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import type { Logger } from 'pino'; import { ensureWorkspace, type BootstrapResult } from '@meebox/config'; import { scaffoldAgentDir } from '@meebox/agent'; import { resolveLanguage } from '@meebox/shared'; import { createLogger } from '@meebox/logger'; -import { createPrAgentBridge, type PrAgentBridge } from '@meebox/pr-agent-bridge'; -import { Poller } from '@meebox/poller'; -import { RepoMirrorManager } from '@meebox/repo-mirror'; -import type { PlatformAdapter, PlatformUser, PrAgentStatus } from '@meebox/shared'; +import type { Poller } from '@meebox/poller'; +import type { RepoMirrorManager } from '@meebox/repo-mirror'; import { JsonFileStateStore } from '@meebox/state-store'; -import { buildAdapters, type ConnectionRuntime } from './adapters.js'; +import { + ConnectionRuntimeController, + PrAgentRuntime, + Updater, + type WindowManager, + applyOsStartupTweaks, + createPoller, + createRepoMirror, + createSplash, + loadWindowManager, +} from './bootstrap/index.js'; import { initMainI18n } from './i18n/index.js'; import { registerIpcHandlers } from './ipc.js'; -import { buildProxyEnv } from './utils/proxy.js'; -import { fixMacPath } from './utils/mac-path.js'; -import { - readConnectionStates, - writeConnectionStates, - type ConnectionState, -} from './utils/connection-state.js'; -import { readWindowState, writeWindowState, type WindowState } from './utils/window-state.js'; -import { checkForUpdate } from './utils/update-check.js'; +import { readConnectionStates } from './utils/connection-state.js'; // 进程(模块加载)起点:用于度量到主窗口首帧(ready-to-show)的启动耗时。 const PROCESS_START_MS = Date.now(); -// 版本更新检测节流:由 poller tick 顺带发起(不另起定时器),至多每小时一次。 -// lastUpdateCheckMs 初值取进程启动时刻 → 首次检测落在启动后约 1h,刻意不在启动瞬间检测, -// 避免占用冷启动网络 / 打断启动;之后随 poller tick 每满 1h 触发一次。 -const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; -let lastUpdateCheckMs = PROCESS_START_MS; - -// 嵌入式 python 子进程不写 .pyc:安装目录(per-user 可写)运行期会积累上万个 __pycache__/.pyc, -// 升级时旧卸载器要 grinding 这些文件 → 卸载极慢、易拖到「应用无法关闭」。设 PYTHONDONTWRITEBYTECODE=1 -// 让运行期不落 .pyc,安装目录文件数稳定(仅装包时的量)。子进程经 spawn 继承本进程 env。 -// 代价:每次 python 启动重编译(略慢);评审为 LLM 网络主导,影响有限。 -process.env.PYTHONDONTWRITEBYTECODE = '1'; - -// Windows 控制台默认活动代码页为本地化 OEM 页(简中为 cp936/GBK),而 Node/pino 按 UTF-8 写出 -// 字节 → 中文日志在 dev 终端显示为乱码。启动期把附着控制台的输出代码页切到 65001(UTF-8),使其与 -// 写出的 UTF-8 字节对齐。仅 win32;无附着控制台(打包态)时 chcp 静默失败,已 try 吞掉、无副作用。 -if (process.platform === 'win32') { - try { - execSync('chcp 65001', { stdio: 'ignore' }); - } catch { - /* 无控制台 / chcp 不可用:忽略,日志仍按 UTF-8 字节写出 */ - } -} - -// macOS 免费(ad-hoc)路线:Chromium 的 os_crypt 首启会建「 Safe Storage」钥匙串项 -// 加密 cookie/本地存储,但 ad-hoc 签名身份不稳定(cdhash 每次构建变) → 每次启动弹「访问钥匙串」。 -// 用 mock keychain 让它走内存、不碰真钥匙串、不再弹。代价:cookie 加密退化为静态 key, -// 但本应用密钥本就明文落盘(config-store),cookie 加密非依赖项,无实质损失。 -// 仅 mac:本开关只控 macOS Keychain 后端;win(DPAPI)/linux(libsecret) 不受影响,故守卫掉。 -// 必须在 app.whenReady() 之前;模块加载期即最早时机。有正式 Developer ID 签名后可移除。 -if (process.platform === 'darwin') { - app.commandLine.appendSwitch('use-mock-keychain'); -} - -// 单例锁:同一时刻只允许一个实例运行。多实例会共享同一份 config.yaml / repos 镜像 / -// state store,导致写竞争、poller 重复轮询、git 镜像并发写冲突,必须互斥。拿不到锁的 -// 第二个实例直接退出,并由已有实例的 second-instance 回调把窗口聚焦到前台。 -const gotSingleInstanceLock = app.requestSingleInstanceLock(); -if (!gotSingleInstanceLock) { - app.quit(); -} - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// registerIpcHandlers 返回的运行时控制句柄:退出时据此中止所有进行中的 pr-agent run(触发其 +// 子进程树清理,见 before-quit);poll tick 顺带做 AutoPilot 准入与清理已消失 PR 的在跑操作。 +type IpcControl = { + abortAllActiveRuns: () => number; + runAutopilotIfDue: () => void; + terminateAgentsForGonePrs: () => void; +}; /** - * 嵌入式 pr-agent 运行时的解释器绝对路径。 - * - dev:`apps/desktop/vendor/pragent/...`(app.getAppPath() = apps/desktop) - * - 打包:`/pragent/...`(electron-builder extraResources) - * - `MEEBOX_PRAGENT_PYTHON` env 覆盖兜底 - * 探测层据此判断 embedded 是否可用(文件不存在则回退 local-cli)。 + * 应用组合根:持有各子系统实例(fields),分域初始化(workspace/日志 → 各 runtime → 连接/poller/IPC → + * 窗口 → 启动轮询)并挂接 app 生命周期。唯一入口 main():进程微调 → 单例锁 → new App().run()。 + * 各 runtime 的构造/工厂均在 bootstrap/ 域内,本类只负责装配与生命周期编排。 */ -function resolveEmbeddedPython(): string { - const override = process.env.MEEBOX_PRAGENT_PYTHON; - if (override) return override; - const rel = - process.platform === 'win32' ? ['python', 'python.exe'] : ['python', 'bin', 'python3']; - const base = app.isPackaged - ? path.join(process.resourcesPath, 'pragent') - : path.join(app.getAppPath(), 'vendor', 'pragent'); - return path.join(base, ...rel); -} - -let bootstrap: BootstrapResult; -let logger: Logger; -// 探测结果异步回填(见下方 kick-off):probe 完成前 bridge=null。 -let prAgentBridge: PrAgentBridge | null = null; -// 探测 promise:app:prAgentStatus 据此 await 拿最终状态;构造逻辑保证恒 resolve、不 reject。 -let prAgentProbe: Promise; -let stateStore: JsonFileStateStore; -let poller: Poller; -let repoMirror: RepoMirrorManager; -// IPC 运行时控制句柄(registerIpcHandlers 返回):退出时据此中止所有进行中的 pr-agent run, -// 触发其子进程树清理(见 before-quit)。注册前为 undefined。 -let ipcControl: - | { - abortAllActiveRuns: () => number; - runAutopilotIfDue: () => void; - terminateAgentsForGonePrs: () => void; +class App { + private bootstrap!: BootstrapResult; + private logger!: Logger; + private stateStore!: JsonFileStateStore; + private poller!: Poller; + private repoMirror!: RepoMirrorManager; + private prAgent!: PrAgentRuntime; + private updater!: Updater; + private conns!: ConnectionRuntimeController; + private windowManager!: WindowManager; + private ipcControl?: IpcControl; + private quitCleanupDone = false; + + constructor(private readonly startMs: number) {} + + /** + * 唯一启动序列:挂接生命周期 → 分域初始化;任一阶段抛错则记 fatal 并退出。 + */ + async run(): Promise { + this.registerLifecycle(); + try { + await this.bootstrapCore(); + this.initRuntimes(); + await this.initConnectionsAndIpc(); + await this.initWindow(); + await this.startPolling(); + } catch (e: unknown) { + if (this.logger) this.logger.fatal({ err: e }, 'startup failed'); + else console.error('meebox startup failed:', e); + app.quit(); } - | undefined; -// 连接级本地状态(按 connectionId):持久化上次 ping 的 currentUser,用于建连接时预热, -// 使首轮 poll 不依赖网络即可判 approved。启动时从 state store 载入一次,ping 后增量回写。 -let connectionStates: Record = {}; -// 主窗口本地状态(尺寸 / 最大化):启动时载入一次,建窗时恢复、resize/move/close 时回写。 -let windowState: WindowState = {}; - -async function start(): Promise { - bootstrap = await ensureWorkspace(); - // 主进程 i18n 定档(dialog 标题、错误消息等面向用户文本)。config.language 为空时 - // 按操作系统偏好语言解析、无合适项回落英语;结果同时供 pr-agent 响应语言复用,与 UI 一致。 - initMainI18n(resolveLanguage(bootstrap.config.language, app.getPreferredSystemLanguages())); - // pretty 仅非打包态开:dev 控制台单行 + ISO8601 + 上色;打包态保持原始 JSON。 - logger = await createLogger({ logsDir: bootstrap.paths.logsDir, pretty: !app.isPackaged }); - logger.info( - { firstRun: bootstrap.firstRun, appDir: bootstrap.paths.appDir }, - 'meebox main process started', - ); - - // Agent 目录脚手架:未配置自定义目录时,Agent 上下文默认落在工作目录下的 agent/(见 ipc.ts - // effectiveAgentDir)。启动期幂等补齐默认目录的模版(已存在不覆盖),使首次使用即有 SOUL/AGENTS - // 等上下文文件可读。失败不阻断启动(运行期 loadAgentContext 仍会按缺失文件降级 + warn)。 - void scaffoldAgentDir(bootstrap.paths.agentDir) - .then((created) => { - if (created.length) logger.info({ created }, 'agent dir scaffolded'); - }) - .catch((err: unknown) => { - logger.warn({ err }, 'scaffold agent dir failed'); - }); - - // macOS GUI 启动(Finder/Dock)只有 launchd 最小 PATH,找不到本机 CLI(claude/codex,常在 - // ~/.local/bin / homebrew)。启动期前置常见目录到 process.env.PATH,使后续 spawn 的嵌入式 - // python 及其 CLI 子进程(经 {...process.env} 继承)都能定位到命令。须在 pr-agent 探测/运行前。 - const macPath = fixMacPath(); - if (macPath.applied) { - logger.info({ added: macPath.added }, 'macOS PATH 已补全'); } - // main 进程全局兜底:未捕获异常 / 未处理 rejection 至少留一条日志,不静默崩溃。 - process.on('uncaughtException', (err) => { - logger.fatal({ err }, 'uncaughtException'); - }); - process.on('unhandledRejection', (reason) => { - logger.error({ err: reason }, 'unhandledRejection'); - }); - - const embeddedPythonPath = resolveEmbeddedPython(); - // pr-agent 探测**不放在建窗关键路径上**:它走 spawn 探测(auto 模式回退 local-cli - // 时最坏 5s 超时),过去 await 在此会把窗口首帧整体推迟数秒。改为 kick-off 不 await, - // 与 app.whenReady() + 渲染层加载并发跑;结果异步回填模块变量。 - // - app:prAgentStatus 会 await prAgentProbe 拿最终状态(boot 时序通常已完成) - // - pragent run 入口读 getPrAgentBridge(),未就绪时为 null → 走"未就绪"提示 - prAgentProbe = (async (): Promise => { - const probe = await createPrAgentBridge({ - embeddedPythonPath, - forceStrategy: bootstrap.config.pr_agent.strategy, + /** + * ① workspace 落定 + i18n 定档 + 日志就绪 + Agent 目录脚手架 + macOS PATH 补全 + 全局兜底。 + */ + private async bootstrapCore(): Promise { + this.bootstrap = await ensureWorkspace(); + // 主进程 i18n 定档(dialog 标题、错误消息等面向用户文本)。config.language 为空时 + // 按操作系统偏好语言解析、无合适项回落英语;结果同时供 pr-agent 响应语言复用,与 UI 一致。 + initMainI18n(resolveLanguage(this.bootstrap.config.language, app.getPreferredSystemLanguages())); + // pretty 仅非打包态开:dev 控制台单行 + ISO8601 + 上色;打包态保持原始 JSON。 + this.logger = await createLogger({ + logsDir: this.bootstrap.paths.logsDir, + pretty: !app.isPackaged, }); - prAgentBridge = probe.bridge; - logger.info( - { - available: probe.status.available, - strategy: probe.status.available ? probe.status.strategy : undefined, - version: probe.status.available ? probe.status.version : undefined, - }, - 'pr-agent probe complete', + this.logger.info( + { firstRun: this.bootstrap.firstRun, appDir: this.bootstrap.paths.appDir }, + 'meebox main process started', ); - return probe.status; - })(); - stateStore = new JsonFileStateStore(bootstrap.paths.stateDir); + // Agent 目录脚手架:未配置自定义目录时,Agent 上下文默认落在工作目录下的 agent/(见 ipc.ts + // effectiveAgentDir)。启动期幂等补齐默认目录的模版(已存在不覆盖),使首次使用即有 SOUL/AGENTS + // 等上下文文件可读。失败不阻断启动(运行期 loadAgentContext 仍会按缺失文件降级 + warn)。 + void scaffoldAgentDir(this.bootstrap.paths.agentDir) + .then((created) => { + if (created.length) this.logger.info({ created }, 'agent dir scaffolded'); + }) + .catch((err: unknown) => { + this.logger.warn({ err }, 'scaffold agent dir failed'); + }); - // 载入连接级本地状态(含上次 ping 的 currentUser)。文件不存在(首跑)→ 空表;读取/解析 - // 失败(损坏)→ 也降级为空表并记一条 warn。降级后果仅是:首轮 poll 无预热身份,待异步 ping - // 补到 currentUser 后自动重分类(见 pingConnections),功能不受损、只是首轮可能短暂偏「待处理」。 - try { - connectionStates = await readConnectionStates(stateStore); - } catch (err) { - logger.warn({ err }, 'read connection states failed; degrade to empty (no cached identities)'); - connectionStates = {}; + // main 进程全局兜底:未捕获异常 / 未处理 rejection 至少留一条日志,不静默崩溃。 + process.on('uncaughtException', (err) => { + this.logger.fatal({ err }, 'uncaughtException'); + }); + process.on('unhandledRejection', (reason) => { + this.logger.error({ err: reason }, 'unhandledRejection'); + }); } - // 载入窗口状态(尺寸/最大化)。缺失或损坏 → 空对象,建窗回退默认尺寸。 - try { - windowState = await readWindowState(stateStore); - } catch (err) { - logger.warn({ err }, 'read window state failed; use default window size'); - windowState = {}; + /** + * ② pr-agent 运行时(解释器 + kick-off 探测)+ 版本更新器 + 状态存储。 + */ + private initRuntimes(): void { + // pr-agent 运行时:解析嵌入式解释器 + kick-off 探测(不 await,不阻塞建窗首帧),结果异步回填。 + this.prAgent = new PrAgentRuntime(this.bootstrap, this.logger); + // 版本更新器:由 poller tick 顺带调 runIfDue(至多每小时一次,复用 poller 周期、不另起定时器)。 + this.updater = new Updater(this.bootstrap, this.logger); + this.stateStore = new JsonFileStateStore(this.bootstrap.paths.stateDir, this.logger); } - // 连接运行时(可变持有):adapters 全量(IPC 按 id 查任意连接,历史 PR 都能操作) - // + adapterByHost(repo-mirror 取 clone url)。reconfigureConnections 原地替换内容, - // IPC handler / repoMirror 经引用读到新值 → 设置页改连接热生效,无需重启。 - const connectionRuntime: ConnectionRuntime = { adapters: [], adapterByHost: new Map() }; - - // 连接「接线」与「ping」解耦,实现「启动不依赖网络」: - // - wireConnections(同步、无网络):重建 adapters/byHost、用本地持久化的上次身份预热 - // currentUser、把活动连接喂给 poller。建窗前即可调用 → app:connections 能力位与首轮判 - // approved 都不等网络。 - // - pingConnections(全异步、有网络):刷新远端身份并增量持久化;活动连接身份因此变化 - // (含「本地无记录、ping 才首次取得」)则补一轮 poll 重新分类。不在启动关键路径。 + /** + * ③ 连接级本地状态 → poller → 连接运行时(接线)→ repoMirror → IPC handlers。 + */ + private async initConnectionsAndIpc(): Promise { + // 载入连接级本地状态(含上次 ping 的 currentUser)。缺失(首跑)/ 损坏 → 降级空表 + warn;后果仅首轮 + // poll 无预热身份,待异步 ping 补到 currentUser 后自动重分类,功能不受损(接线 / ping 见 connections-runtime)。 + const connectionStates = await readConnectionStates(this.stateStore).catch((err: unknown) => { + this.logger.warn( + { err }, + 'read connection states failed; degrade to empty (no cached identities)', + ); + return {}; + }); - const activeConnectionIds = (): string[] => - connectionRuntime.adapters - .filter((a) => a.connectionId === bootstrap.config.active_connection_id) - .map((a) => a.connectionId); + this.poller = createPoller({ + bootstrap: this.bootstrap, + stateStore: this.stateStore, + logger: this.logger, + onTickExtras: () => { + // 本轮已被移除 / purge 的 PR:终止其上仍在执行的 agent 操作(先于 AutoPilot,避免给已消失的 PR 起新评审)。 + this.ipcControl?.terminateAgentsForGonePrs(); + // 顺带做版本更新检测:内部时间戳门控成每小时至多一次,复用 poller 周期、不另起定时器。 + void this.updater.runIfDue(); + // AutoPilot 预评审:满足开关 + 最小间隔 + 候选时跑一遍 pass(内部门控,复用 poller 周期)。 + this.ipcControl?.runAutopilotIfDue(); + }, + getRepoMirror: () => this.repoMirror, + }); - const wireConnections = (): void => { - const adapters = buildAdapters(bootstrap.config.connections, bootstrap.config.proxy); - const byHost = new Map(); - for (const { connectionId, adapter } of adapters) { - // 预热 currentUser:有本地记录就先填上(无记录则保持 null,由 pingConnections 兜底)。 - const cachedUser = connectionStates[connectionId]?.user; - if (cachedUser) adapter.setCurrentUser?.(cachedUser); - const conn = bootstrap.config.connections.find((c) => c.id === connectionId); - if (!conn) continue; - try { - byHost.set(new URL(conn.base_url).hostname, adapter); - } catch (err) { - logger.warn({ err, connectionId, base_url: conn.base_url }, 'invalid base_url'); - } - } - connectionRuntime.adapters = adapters; - connectionRuntime.adapterByHost = byHost; - // 只轮询当前启用的连接(同时仅一条);其余仅保留配置不轮询 - poller.setConnections( - adapters.filter((a) => a.connectionId === bootstrap.config.active_connection_id), + // 连接运行时(接线 / ping / 热重配):依赖已建好的 poller;repoMirror 经 conns.runtime 读 adapterByHost。 + this.conns = new ConnectionRuntimeController( + this.bootstrap, + this.stateStore, + this.poller, + this.logger, + connectionStates, ); - }; - // 持久化某连接的 currentUser(仅身份变化时写盘,避免无谓 IO)。写盘失败不影响运行。 - const persistConnectionUser = async ( - connectionId: string, - user: PlatformUser | null, - ): Promise => { - const prevName = connectionStates[connectionId]?.user?.name ?? null; - if (prevName === (user?.name ?? null)) return; - connectionStates = { - ...connectionStates, - [connectionId]: { ...connectionStates[connectionId], user }, - }; - try { - await writeConnectionStates(stateStore, connectionStates); - } catch (err) { - logger.warn({ err, connectionId }, 'persist connection user failed'); - } - }; - - // 全异步 ping:刷新 + 持久化 currentUser;活动连接身份变化(含首次取得)则补一轮 poll 重新分类。 - const pingConnections = (): void => { - const activeId = bootstrap.config.active_connection_id; - for (const { connectionId, adapter } of connectionRuntime.adapters) { - const isActive = connectionId === activeId; - const beforeName = adapter.getCurrentUser()?.name ?? null; - // 活动连接启动时无缓存身份 → poller.start(immediate=false) 没跑首轮;此处 ping settle 后 - // 必须触发**首次同步**(无论 ping 成功与否:成功则带确认的身份分类,失败也用 PAT 拉一轮, - // 不让「无身份」永远等到下个 interval)。这就是「先确认身份,再立即同步一次」。 - const hadIdentity = beforeName !== null; - void adapter.ping().then( - async (r) => { - logger.info( - { connectionId, ok: r.ok, serverVersion: r.serverVersion, user: r.user?.name }, - 'adapter ping', - ); - const user = adapter.getCurrentUser(); - await persistConnectionUser(connectionId, user); - // 触发重分类/首次同步:活动连接且(身份变化 含首次取得/换号,或本就无身份需补首轮)。 - // poller.tick 已做「进行中则补跑」,不会因撞上首轮 poll 而丢失。 - if (isActive && (!hadIdentity || (user?.name ?? null) !== beforeName)) { - void poller.tick(); - } - }, - (err: unknown) => { - logger.warn({ err, connectionId }, 'adapter ping failed'); - // ping 失败但活动连接本就无缓存身份(首轮被跳过)→ 仍用 PAT 兜底同步一次,避免看似没同步。 - if (isActive && !hadIdentity) void poller.tick(); - }, - ); - } - }; + this.repoMirror = createRepoMirror({ + bootstrap: this.bootstrap, + logger: this.logger, + connectionRuntime: this.conns.runtime, + }); - // 设置页 config:setConnections / setProxy 后的热生效:重接线 + 归档非活动连接(本地 IO)+ 异步 - // ping。调用方(IPC)随后会 poller.tick() 立即刷新列表,ping 完成若改了身份会再补一轮。 - const reconfigureConnections = async (): Promise => { - wireConnections(); - await poller.archiveConnectionsExcept(activeConnectionIds()); - pingConnections(); - }; + // 建窗前同步把连接接好(无网络):构建 adapters、用本地持久化身份预热 currentUser、喂 poller。 + // 这样 app:connections 与首轮判 approved 都不依赖网络;ping 留到建窗后全异步刷新。 + this.conns.wire(); + + this.ipcControl = registerIpcHandlers({ + bootstrap: this.bootstrap, + logger: this.logger, + // 惰性读取:探测异步回填后,handler 调用时才取到最新值(注册时探测可能尚未完成) + getPrAgentStatus: () => this.prAgent.probe, + getPrAgentBridge: () => this.prAgent.getBridge(), + embeddedPythonPath: this.prAgent.embeddedPythonPath, + stateStore: this.stateStore, + poller: this.poller, + connectionRuntime: this.conns.runtime, + reconfigureConnections: () => this.conns.reconfigure(), + repoMirror: this.repoMirror, + }); + } - poller = new Poller({ - connections: [], - stateStore, - intervalSeconds: bootstrap.config.poller.interval_seconds, - logger: logger.child({ scope: 'poller' }), - onTick: (info) => { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('poll:tick', info); - } - // 本轮已被移除 / purge 的 PR:终止其上仍在执行的 agent 操作(先于 AutoPilot,避免给已消失的 PR 起新评审)。 - ipcControl?.terminateAgentsForGonePrs(); - // 顺带做版本更新检测:内部时间戳门控成每小时至多一次,复用 poller 周期、不另起定时器 - void runUpdateCheckIfDue(); - // AutoPilot 预评审:满足开关 + 最小间隔 + 候选时跑一遍 pass(内部门控,复用 poller 周期)。 - ipcControl?.runAutopilotIfDue(); - }, - // PR 新增 / 内容变更时,顺手 syncMirror 把本地镜像跟上,让用户随后点开 PR - // 时省一趟 fetch。失败不阻断 poll 流程 (mirror 也有自己的全局队列 + 错误隔离) - onPrsChanged: (repos) => { - for (const r of repos) { - const conn = bootstrap.config.connections.find((c) => c.id === r.connectionId); - if (!conn) continue; - let host: string; - try { - host = new URL(conn.base_url).hostname; - } catch { - continue; - } - // identity 字段映射:poller 用 group/repo 中性命名,repo-mirror 仍保留 - // Bitbucket-shaped projectKey/repoSlug (跟 git 路径布局一致,沿用便于排障) - void repoMirror.syncMirror({ host, projectKey: r.group, repoSlug: r.repo }).catch((err) => { - logger.warn({ err, repo: r }, 'auto syncMirror after poll failed'); - }); - } - }, - }); + /** + * ④ whenReady 后的原生 chrome(菜单/Dock 图标/深色)+ splash + 主窗口 + activate 重建。 + */ + private async initWindow(): Promise { + // 不要 Electron 默认菜单栏(File/Edit/View/...),meebox 自己提供工具栏 + Menu.setApplicationMenu(null); - repoMirror = new RepoMirrorManager({ - reposDir: bootstrap.paths.reposDir, - getCloneUrl: async (repo) => { - const adapter = connectionRuntime.adapterByHost.get(repo.host); - if (!adapter) throw new Error(`no adapter for host ${repo.host}`); - return adapter.getCloneUrl({ - projectKey: repo.projectKey, - repoSlug: repo.repoSlug, - }); - }, - logger: logger.child({ scope: 'repo-mirror' }), - onProgress: (event) => { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('sync:progress', event); - } - }, - // 出站代理:getter 每次远端 clone/fetch 求值,设置页改代理后即生效。 - proxyEnv: () => buildProxyEnv(bootstrap.config.proxy), - }); + await app.whenReady(); - // 建窗前同步把连接接好(无网络):构建 adapters、用本地持久化身份预热 currentUser、喂 poller。 - // 这样 app:connections 与首轮判 approved 都不依赖网络;ping 留到建窗后全异步刷新。 - wireConnections(); + // dev 下 Dock 图标走通用 Electron.app(未经 electron-builder 烤 icns)→ 手动设成 mac 专用图标。 + // 打包态 Dock 图标由 bundle 的 icns 决定,无需且不应在此覆盖。仅 mac 有 app.dock。 + if (process.platform === 'darwin' && !app.isPackaged) { + app.dock?.setIcon(path.join(app.getAppPath(), '../../assets/icons/icon-mac.png')); + } - ipcControl = registerIpcHandlers({ - bootstrap, - logger, - // 惰性读取:探测异步回填后,handler 调用时才取到最新值(注册时探测可能尚未完成) - getPrAgentStatus: () => prAgentProbe, - getPrAgentBridge: () => prAgentBridge, - embeddedPythonPath, - stateStore, - poller, - connectionRuntime, - reconfigureConnections, - repoMirror, - }); + // 强制原生窗口 chrome 走深色:Windows 据此设 DWMWA_USE_IMMERSIVE_DARK_MODE,原生标题栏 + + // 那条细边框渲染成深色,与 #1e1e1e 应用一致,不再跟随系统浅色主题(splash + 主窗口都受益)。 + // macOS/Linux 无副作用;只影响原生 chrome,应用本身已是自绘深色样式。 + nativeTheme.themeSource = 'dark'; - // 不要 Electron 默认菜单栏(File/Edit/View/...),meebox 自己提供工具栏 - Menu.setApplicationMenu(null); + // 主窗口管理(载入窗口状态 + 建窗 + 尺寸回写)。 + this.windowManager = await loadWindowManager({ + stateStore: this.stateStore, + logger: this.logger, + startMs: this.startMs, + }); - await app.whenReady(); + // 先弹轻量 splash(data URL,几十 ms 即可见),遮住主窗口首帧前的 ~2s 加载空窗。 + // 主窗口 ready-to-show 时关闭它。 + const splash = createSplash(); + this.windowManager.create(splash); - // dev 下 Dock 图标走通用 Electron.app(未经 electron-builder 烤 icns)→ 手动设成 mac 专用图标。 - // 打包态 Dock 图标由 bundle 的 icns 决定,无需且不应在此覆盖。仅 mac 有 app.dock。 - if (process.platform === 'darwin' && !app.isPackaged) { - app.dock?.setIcon(path.join(app.getAppPath(), '../../assets/icons/icon-mac.png')); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) this.windowManager.create(); + }); } - // 强制原生窗口 chrome 走深色:Windows 据此设 DWMWA_USE_IMMERSIVE_DARK_MODE,原生标题栏 + - // 那条细边框渲染成深色,与 #1e1e1e 应用一致,不再跟随系统浅色主题(splash + 主窗口都受益)。 - // macOS/Linux 无副作用;只影响原生 chrome,应用本身已是自绘深色样式。 - nativeTheme.themeSource = 'dark'; - - // 先弹轻量 splash(data URL,几十 ms 即可见),遮住主窗口首帧前的 ~2s 加载空窗。 - // 主窗口 ready-to-show 时关闭它。 - const splash = createSplash(); - createWindow(splash); - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); - }); - - // 启动关键路径已无网络调用:归档(本地 IO)后启动 poller。 - await poller.archiveConnectionsExcept(activeConnectionIds()); - // 活动连接是否已有缓存身份(wireConnections 已用本地持久化身份预热): - // - 有 → poller 立即跑首轮(me 就绪,分类正确); - // - 无 → **不跑 me=null 的半成品首轮**,只装定时器;首次同步改由下面 pingConnections 在 ping - // 确认身份后立即触发(见 pingConnections:无身份的活动连接 ping settle 后必 tick 一次)。 - // 这样「首次启动 / state 缺失」时也是「先确认身份,再同步一次」,避免首轮全标 pending 或看似没同步。 - const activeHasIdentity = connectionRuntime.adapters.some( - (a) => - a.connectionId === bootstrap.config.active_connection_id && - a.adapter.getCurrentUser() != null, - ); - poller.start(activeHasIdentity); - logger.info( - { - connections: bootstrap.config.connections.length, - activeId: bootstrap.config.active_connection_id, - activeHasIdentity, - }, - 'poller started', - ); - // ping 全异步刷新远端身份(不在启动关键路径):刷新/持久化 currentUser,身份变化则补一轮 poll。 - // 这是「本地无身份记录」(首跑 / state 缺失)时的兜底来源;ping 慢或不可达都不影响已启动的 UI。 - pingConnections(); -} + /** + * ⑤ 启动关键路径已无网络:归档(本地 IO)后启动 poller,再异步 ping 刷新远端身份。 + */ + private async startPolling(): Promise { + await this.poller.archiveConnectionsExcept(this.conns.activeConnectionIds()); + // 活动连接是否已有缓存身份(conns.wire 已用本地持久化身份预热): + // - 有 → poller 立即跑首轮(me 就绪,分类正确); + // - 无 → **不跑 me=null 的半成品首轮**,只装定时器;首次同步改由下面 conns.ping 在 ping + // 确认身份后立即触发(无身份的活动连接 ping settle 后必 tick 一次)。 + // 这样「首次启动 / state 缺失」时也是「先确认身份,再同步一次」,避免首轮全标 pending 或看似没同步。 + const activeHasIdentity = this.conns.runtime.adapters.some( + (a) => + a.connectionId === this.bootstrap.config.active_connection_id && + a.adapter.getCurrentUser() != null, + ); + this.poller.start(activeHasIdentity); + this.logger.info( + { + connections: this.bootstrap.config.connections.length, + activeId: this.bootstrap.config.active_connection_id, + activeHasIdentity, + }, + 'poller started', + ); + // ping 全异步刷新远端身份(不在启动关键路径):刷新/持久化 currentUser,身份变化则补一轮 poll。 + // 这是「本地无身份记录」(首跑 / state 缺失)时的兜底来源;ping 慢或不可达都不影响已启动的 UI。 + this.conns.ping(); + } -// 退出清理:停轮询 + 终止所有进行中的 pr-agent run 的子进程树(python + litellm 等孙进程)。 -// 不清理会留孤儿进程锁住安装目录 → 升级时 NSIS 报「应用无法关闭」。 -let quitCleanupDone = false; -app.on('before-quit', (event) => { - if (poller) poller.stop(); - if (quitCleanupDone) return; - const aborted = ipcControl?.abortAllActiveRuns() ?? 0; - if (aborted === 0) return; // 无进行中 run,直接退出 - // 有 run 在跑:abort 已触发各自 exec 的 killTree(win32=taskkill /T /F,异步)。延后真正退出, - // 给 taskkill 跑完,避免主进程先退出、孙进程没杀干净。 - event.preventDefault(); - quitCleanupDone = true; - if (logger) logger.info({ abortedRuns: aborted }, 'terminating active pr-agent runs before quit'); - setTimeout(() => app.quit(), 800); -}); + /** + * app 级生命周期:退出清理(停轮询 + 终止在跑 run 的子进程树)、关窗退出、二次启动聚焦。 + */ + private registerLifecycle(): void { + // 退出清理:停轮询 + 终止所有进行中的 pr-agent run 的子进程树(python + litellm 等孙进程)。 + // 不清理会留孤儿进程锁住安装目录 → 升级时 NSIS 报「应用无法关闭」。 + app.on('before-quit', (event) => { + if (this.poller) this.poller.stop(); + if (this.quitCleanupDone) return; + const aborted = this.ipcControl?.abortAllActiveRuns() ?? 0; + if (aborted === 0) return; // 无进行中 run,直接退出 + // 有 run 在跑:abort 已触发各自 exec 的 killTree(win32=taskkill /T /F,异步)。延后真正退出, + // 给 taskkill 跑完,避免主进程先退出、孙进程没杀干净。 + event.preventDefault(); + this.quitCleanupDone = true; + if (this.logger) { + this.logger.info({ abortedRuns: aborted }, 'terminating active pr-agent runs before quit'); + } + setTimeout(() => app.quit(), 800); + }); -/** - * 读取品牌 logo 并转成 base64 data URI,内联进 splash data URL(splash 是独立 data URL - * 文档,无法走 file:// 相对路径引用资源,故必须内联)。两路探测: - * - 打包态:`/icon.png`(electron-builder extraResources copy) - * - dev:仓库 `assets/icons/icon.png` - * 两路都读不到(如 LFS 未拉取)则返回 null,splash 优雅回退为纯 spinner。 - */ -function resolveSplashLogo(): string | null { - const candidates = [ - path.join(process.resourcesPath, 'icon.png'), - path.join(app.getAppPath(), '../../assets/icons/icon.png'), - ]; - for (const p of candidates) { - try { - const buf = readFileSync(p); - // LFS 指针文件不是合法 PNG(无 \x89PNG magic)→ 跳过,避免 splash 显示裂图 - if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) continue; - return `data:image/png;base64,${buf.toString('base64')}`; - } catch { - /* 试下一个候选 */ - } - } - return null; -} + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); + }); -/** - * 版本更新检测(config.update.check_enabled 开启时)。由 poller tick 顺带发起,内部用 - * lastUpdateCheckMs 时间戳门控成「至多每小时一次」——复用既有 poll 周期,不引入额外定时器。 - * 仅检测 + 提示:有新版才广播给所有窗口(StatusBar 提示 + 跳转下载),不下载 / 不安装。失败静默。 - * - * 时间戳在 await 前即更新,避免 await 窗口内下一次 tick 重复发起。 - */ -async function runUpdateCheckIfDue(): Promise { - if (!bootstrap.config.update.check_enabled) return; - if (Date.now() - lastUpdateCheckMs < UPDATE_CHECK_INTERVAL_MS) return; - lastUpdateCheckMs = Date.now(); - try { - const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy); - // 获取失败(网络 / 解析 / 超时 / 限流,ok=false):只记 debug 日志,**绝不推任何 IPC** → - // 渲染层完全无感,不弹任何提示 / chip。保证「拿不到更新信息」对用户零打扰。 - if (!result.ok) { - logger.debug({ error: result.error }, 'update check failed (silent, no prompt)'); - return; - } - // 仅「检测成功且确有新版」才广播;ok=true&hasUpdate=false(已是最新)同样静默。 - if (result.hasUpdate) { - for (const win of BrowserWindow.getAllWindows()) { - if (!win.isDestroyed()) win.webContents.send('app:updateAvailable', result); + // 二次启动(用户再点图标 / 命令行再拉起)→ 聚焦已有窗口,最小化则先还原。 + app.on('second-instance', () => { + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); } - logger.info( - { current: result.currentVersion, latest: result.latestVersion }, - 'update available', - ); - } - } catch (err) { - // 兜底:checkForUpdate 约定不抛;万一抛了也吞掉,绝不冒泡成任何用户可见行为。 - logger.debug({ err }, 'update check threw (silent, no prompt)'); + }); } } /** - * 启动闪屏:独立的无边框轻量窗口,加载内联 data URL(品牌 logo + 纯 CSS spinner), - * 几十 ms 即可呈现,遮住主窗口首帧前的渲染层加载空窗。主窗口 ready-to-show 时关闭。 - * logo 经 base64 内联(见 resolveSplashLogo),data URL 自包含、dev/打包行为一致。 + * 进程入口: + * ① OS/平台启动微调(须在 whenReady 前;含 Windows 控制台编码 / macOS keychain + PATH 补全)。 + * ② 单例锁——同一时刻只允许一个实例,多实例会共享同一份 config.yaml / repos 镜像 / state store + * 导致写竞争,拿不到锁者直接退出(由已有实例的 second-instance 回调聚焦窗口)。 + * ③ 拿到锁则构造 App 并启动。 */ -function createSplash(): BrowserWindow { - const splash = new BrowserWindow({ - width: 280, - height: 240, - frame: false, - resizable: false, - movable: false, - center: true, - show: false, - alwaysOnTop: true, - skipTaskbar: true, - backgroundColor: '#1e1e1e', - webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, - }); - const logo = resolveSplashLogo(); - const logoEl = logo ? `` : ''; - const html = ` - ${logoEl}
Code Meeseeks
-
启动中…
- `; - void splash.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); - splash.once('ready-to-show', () => { - if (!splash.isDestroyed()) splash.show(); - }); - return splash; -} - -function createWindow(splash?: BrowserWindow): void { - // 最小尺寸保证核心三栏 (sidebar 240 + file-tree 180 + diff 内容) - // 在 chat-pane 折叠态下仍可用;高度兜住 pr-header + tabs + diff + statusbar - // 尺寸优先用本地记录的上次大小(windowState),无记录回退默认 1280×800。 - const win = new BrowserWindow({ - width: windowState.width ?? 1280, - height: windowState.height ?? 800, - minWidth: 960, - minHeight: 600, - show: false, - // 首帧前的窗口底色与 app 一致,避免显示瞬间白闪 - backgroundColor: '#1e1e1e', - // 无边框 + 自绘标题栏(VS Code 风):macOS 保留红绿灯并下移到自绘标题栏内; - // Windows/Linux 用 titleBarOverlay 让系统继续画窗控按钮(最小化/最大化/关闭), - // 渲染层只接管中间标题区。高度需与渲染层 .app-titlebar 一致(36px)。 - titleBarStyle: 'hidden', - ...(process.platform === 'darwin' - ? { trafficLightPosition: { x: 12, y: 11 } } - : { titleBarOverlay: { color: '#1e1e1e', symbolColor: '#cccccc', height: 36 } }), - // dev 下显式给窗口图标(assets/icons/icon.ico);打包态窗口/任务栏图标走 exe - // 内嵌(electron-builder),且 assets 不进 asar,故仅 dev 设置 - icon: app.isPackaged ? undefined : path.join(app.getAppPath(), '../../assets/icons/icon.ico'), - webPreferences: { - preload: path.join(__dirname, '../preload/index.mjs'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - - // 记住窗口大小:resize/move 防抖回写、关闭时立即回写。getNormalBounds 取「非最大化」尺寸, - // 故最大化时记录的仍是还原后的正常大小。写盘失败不影响使用。 - const persistWindowState = (): void => { - if (win.isDestroyed()) return; - const b = win.getNormalBounds(); - windowState = { width: b.width, height: b.height, maximized: win.isMaximized() }; - void writeWindowState(stateStore, windowState).catch((err: unknown) => { - logger.warn({ err }, 'persist window state failed'); - }); - }; - let saveTimer: ReturnType | undefined; - const scheduleSave = (): void => { - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(persistWindowState, 400); - }; - win.on('resize', scheduleSave); - win.on('move', scheduleSave); - win.on('close', persistWindowState); - - // 主界面首帧就绪:恢复最大化态 → 显示主窗口 → 关闭 splash,并记录进程启动→首帧耗时。 - // maximize 必须放到这里:`win.maximize()` 会立即「显示」隐藏窗口,若在建窗后即调用,无边框 - // 主窗口会在内容就绪前以空白态抢先出现(盖过/早于 splash)。改到 ready-to-show 内与首帧一起 - // maximize + show,再关 splash → 启动期只见 splash,主窗口一出现即为已渲染态。 - win.once('ready-to-show', () => { - if (windowState.maximized) win.maximize(); - win.show(); - if (splash && !splash.isDestroyed()) splash.close(); - logger.info( - { elapsedMs: Date.now() - PROCESS_START_MS }, - 'main window first paint (ready-to-show)', - ); - }); - - // 把
/ window.open 都路由到 OS 默认浏览器,不在 Electron 内开新窗口 - win.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url); - return { action: 'deny' }; - }); - - if (process.env.ELECTRON_RENDERER_URL) { - void win.loadURL(process.env.ELECTRON_RENDERER_URL); - } else { - void win.loadFile(path.join(__dirname, '../renderer/index.html')); +function main(): void { + applyOsStartupTweaks(); + if (!app.requestSingleInstanceLock()) { + app.quit(); + return; } + void new App(PROCESS_START_MS).run(); } -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit(); -}); - -// 仅在拿到单例锁时才真正启动;否则上面已 app.quit(),不跑业务初始化。 -if (gotSingleInstanceLock) { - // 二次启动(用户再点图标 / 命令行再拉起)→ 聚焦已有窗口,最小化则先还原。 - app.on('second-instance', () => { - const win = BrowserWindow.getAllWindows()[0]; - if (win) { - if (win.isMinimized()) win.restore(); - win.focus(); - } - }); - - start().catch((e: unknown) => { - if (logger) logger.fatal({ err: e }, 'startup failed'); - else console.error('meebox startup failed:', e); - app.quit(); - }); -} +main(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index ef19092d..16af4616 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,2228 +1,131 @@ -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; -import { execFile } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import type { Logger } from 'pino'; +import { ipcMain } from 'electron'; +import * as agent from './controllers/agent.js'; +import * as app from './controllers/app.js'; +import * as config from './controllers/config.js'; +import * as pr from './controllers/pr.js'; +import { Orchestrator } from './services/agent/index.js'; import { - appendAgentNotes, - buildToolCatalog, - judgeAutopilotBatch, - loadAgentContext, - loadAgentRules, -} from '@meebox/agent'; -import type { AgentContext } from '@meebox/agent'; -import { writeConfig, type BootstrapResult } from '@meebox/config'; -import { PrAgentRunError, type PrAgentBridge } from '@meebox/pr-agent-bridge'; -import { - type Poller, - createDraft, - deleteDraft, - dropPendingFindingDrafts, - finishReviewRun, - getReviewRun, - isCommentsCacheStale, - listDrafts, - listReviewRunsForPr, - hasReviewOutput, - clearReviewRunsForPr, - listStoredPullRequests, - makeRunId, - parseReviewOutput, - readCommentsCache, - readDiffBaseCache, - writeDiffBaseCache, - setLocalStatus, - startReviewRun, - updateDraft, - writeCommentsCache, - writeAutopilotLedger, - getAutopilotLedger, - clearAutopilotLedger, - getAgentSession, - clearAgentSession, - getAgentConversation, - getAgentTranscript, - appendAgentMessage, -} from '@meebox/poller'; -import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; -import { pickMatchingRule } from '@meebox/rules'; -import type { - AgentRecommendationVerdict, - AgentSession, - AgentStep, - AppInfo, - ConnectionSummary, - IpcChannels, - PlatformAdapter, - PrAgentStatus, - PrComment, - PragentRunInfo, - ReviewRun, - ReviewRunStatus, - ReviewRunTool, - StoredPullRequest, - TokenUsage, -} from '@meebox/shared'; -import type { JsonFileStateStore } from '@meebox/state-store'; -import { buildDraftAdapter, type BuiltAdapter, type ConnectionRuntime } from './adapters.js'; -import { t, getMainLanguage, setMainLanguage } from './i18n/index.js'; -import { sniffImageContentType } from './utils/image.js'; -import { buildPragentEnv, resolveActiveLlmProfile } from './utils/agent.js'; -import { buildProxyEnv, testProxyConnectivity } from './utils/proxy.js'; -import { checkForUpdate } from './utils/update-check.js'; -import { buildPrContext } from './utils/pr-context.js'; -import { runAgentReview } from './agent-review.js'; -import { runAgentPlanning } from './agent-planning.js'; + createServiceContext, + setControllerContext, + type ControllerContext, + type RegisterDeps, +} from './services/context.js'; +import { RunQueue } from './services/pr-agent/index.js'; -interface RegisterDeps { - bootstrap: BootstrapResult; - logger: Logger; - /** 惰性取 pr-agent 探测状态:探测异步进行(不阻塞建窗),await 拿最终结果 */ - getPrAgentStatus: () => Promise; - /** 惰性取 bridge 实例;探测未完成 / 不可用 (embedded / CLI 都没有) 时为 null */ - getPrAgentBridge: () => PrAgentBridge | null; - /** 嵌入式运行时解释器路径(embedded 策略下执行期补 .secrets.toml 用),非 embedded 可空 */ - embeddedPythonPath?: string; - stateStore: JsonFileStateStore; - poller: Poller; - /** 可变连接运行时(全量 adapters + adapterByHost);设置页改连接后被 reconfigure 原地替换 */ - connectionRuntime: ConnectionRuntime; - /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ - reconfigureConnections: () => Promise; - repoMirror: RepoMirrorManager; -} +export type { RegisterDeps } from './services/context.js'; /** - * 注册全部 IPC handler。后续新增 channel 时只需扩 IpcChannels + 在此添加一个 handle。 - * 故意保持显式,每个 channel 一行映射,方便审计 main↔renderer 暴露面。 + * 注册全部 IPC handler。薄入口:构建共享上下文 → 建两个跨域 service(run 队列 / Agent 编排) + * → 合成 controller 上下文并安装为进程级单例 → 按业务领域逐个绑定通道 → 返回运行时控制句柄。 + * + * controller 是原生 ipcMain.handle 监听器(具名函数 `(event, req) => …`,见 controllers/<域>.ts), + * 依赖经 getContext() 取用、不带 ctx 参数;下方直接 `ipcMain.handle('channel', controller)` 注册,无包装层。 */ -export function registerIpcHandlers({ - bootstrap, - logger, - getPrAgentStatus, - getPrAgentBridge, - embeddedPythonPath, - stateStore, - poller, - connectionRuntime, - reconfigureConnections, - repoMirror, -}: RegisterDeps): { +export function registerIpcHandlers(deps: RegisterDeps): { abortAllActiveRuns: () => number; runAutopilotIfDue: () => void; terminateAgentsForGonePrs: () => void; } { - // === pr-agent run 队列 === - // - // FIFO 队列,同时只有 1 条在跑 (避免撞 LLM rate limit / 抢 worktree), - // 其余在 waiting 排队。每次 active 完成 / 取消 → 自动开下一条。 - // - // 设计要点: - // - runId 在入队时就分配 (跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 - // active / waiting 两种状态都能精确定位 - // - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact - // - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent - // - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 - interface QueueItem { - info: PragentRunInfo; - req: { localId: string; tool: ReviewRunTool; question?: string }; - pr: StoredPullRequest; - resolve: (run: ReviewRun) => void; - reject: (err: Error) => void; - /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。见 §7 调度。 */ - priority: 'user' | 'agent'; - /** 仅 active 状态填;用于 cancel SIGKILL */ - ac?: AbortController; - } - const waiting: QueueItem[] = []; - // 并发运行中的 run(runId → item);上限 maxConcurrency。post-Docker 下每个 run - // 独立 worktree(路径带 nonce)+ 独立子进程,并发安全;串行不再是正确性要求。 - const active = new Map(); - const maxConcurrency = bootstrap.config.pr_agent.max_concurrency; - - const broadcastQueueChanged = (): void => { - const payload = { - active: [...active.values()].map((q) => q.info), - waiting: waiting.map((q) => q.info), - }; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('pragent:queueChanged', payload); - } - }; - - /** 草稿变更广播:drafts:* IPC 写盘后调用,告诉 renderer 重拉某 PR 的草稿列表 */ - const broadcastDraftsChanged = (localId: string): void => { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('drafts:changed', { localId }); - } - }; - - const findPrOrThrow = async (localId: string): Promise => { - const prs = await listStoredPullRequests(stateStore); - const pr = prs.find((p) => p.localId === localId); - if (!pr) throw new Error(`PR not found in local state: ${localId}`); - return pr; - }; - - /** 生效的 Agent 目录:用户配置优先,未配置则回落工作目录默认位置(~/.code-meeseeks/agent)。 */ - const effectiveAgentDir = (): string => bootstrap.config.agent.dir || bootstrap.paths.agentDir; - - // /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), - // 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 - const stripAskQuestionEcho = (md: string, ...echoed: string[]): string => { - const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); - if (!qs.size || !md) return md; - return md - .split('\n') - .filter((line) => !qs.has(line.trim())) - .join('\n'); - }; - - // embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 - // .secrets.toml(pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥 - // 不用 secrets.toml,写个空文件压掉告警)。 - // memo 化:只在首个 embedded run 解析一次 pr_agent 目录 + 写文件,后续直接复用。 - // importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 - const execFileP = promisify(execFile); - let embeddedSecretsEnsured: Promise | null = null; - const ensureEmbeddedSecrets = (pythonPath: string): Promise => { - embeddedSecretsEnsured ??= (async () => { - const { stdout } = await execFileP(pythonPath, [ - '-c', - "import importlib.util,os;print(os.path.dirname(importlib.util.find_spec('pr_agent').origin))", - ]); - const prAgentDir = stdout.trim(); - for (const sub of ['settings', 'settings_prod']) { - const dir = path.join(prAgentDir, sub); - await fs.mkdir(dir, { recursive: true }); - const f = path.join(dir, '.secrets.toml'); - try { - await fs.access(f); - } catch { - await fs.writeFile( - f, - '# meebox placeholder: silence pr-agent warning about a missing .secrets.toml\n', - ); - } - } - })().catch((err: unknown) => { - logger.warn({ err }, 'ensure embedded .secrets.toml failed (ignored)'); - }); - return embeddedSecretsEnsured; - }; - - const repoIdentityFor = (pr: StoredPullRequest): RepoIdentity => { - const conn = bootstrap.config.connections.find((c) => c.id === pr.connectionId); - if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); - return { - host: new URL(conn.base_url).hostname, - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - }; - }; - - ipcMain.handle('app:info', (): IpcChannels['app:info']['response'] => buildAppInfo(bootstrap)); - ipcMain.handle('app:paths', (): IpcChannels['app:paths']['response'] => bootstrap.paths); - ipcMain.handle( - 'app:prAgentStatus', - (): Promise => getPrAgentStatus(), - ); - // 渲染层日志回传:落进同一份 meebox.log(scope=renderer),与 main 日志合流便于排查。 - const rendererLogger = logger.child({ scope: 'renderer' }); - ipcMain.handle('log:write', (_evt, req: IpcChannels['log:write']['request']): void => { - const obj = req.meta ?? {}; - switch (req.level) { - case 'error': - rendererLogger.error(obj, req.msg); - break; - case 'warn': - rendererLogger.warn(obj, req.msg); - break; - case 'info': - rendererLogger.info(obj, req.msg); - break; - case 'debug': - rendererLogger.debug(obj, req.msg); - break; - } - }); - ipcMain.handle('app:connections', (): IpcChannels['app:connections']['response'] => - buildConnectionSummaries(bootstrap, connectionRuntime.adapters), - ); - - // (connectionId, slug) → dataUrl 或 null。两级 cache: - // 1) avatarMem: 进程内 Map,本会话内瞬时返回(含 null 负缓存避免重试失败 slug) - // 2) 磁盘文件 /avatars/.bin,TTL 7 天,按 mtime 判定过期 - // 过期或不存在 → 重新打 Bitbucket → 写回磁盘 - // hash = sha256(connectionId|slug) 前 24 hex,纯字母数字文件名安全 - const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; - const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); - const avatarMem = new Map(); - - ipcMain.handle( - 'comments:reply', - async ( - _evt, - req: IpcChannels['comments:reply']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - const reply = await adapter.replyToComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.parentCommentId, - req.body, - ); - // 清掉 comments cache,下次 listComments 会 force 拉远端拿到最新评论树 - // (包括刚 post 的 reply 嵌入到正确父评论 .replies 数组)。同时广播事件让 - // CommentsPanel / DiffView 自动重拉 - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - return reply; - }, - ); - - ipcMain.handle( - 'comments:delete', - async ( - _evt, - req: IpcChannels['comments:delete']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // Bitbucket 在以下情形 409/403: - // - version 跟远端不一致 (用户在别处已编辑) - // - 评论已有回复 (跟 web UI 同步规则) - // - 当前 PAT 不是作者本人 - // 错误体已经在 BitbucketClientError.message 里带,直接抛给 renderer 显示原文 - await adapter.deleteComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.commentId, - req.version, - ); - // 跟 reply 同套:清 cache + 广播让 UI 立刻看到评论消失 - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - }, - ); - - ipcMain.handle( - 'comments:edit', - async ( - _evt, - req: IpcChannels['comments:edit']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // Bitbucket 409 (version 不一致) 时 BitbucketClientError.message 会带 "expected version X" - // 这种细节,原样抛给 renderer 显示让用户知道"远端有新版本" - const updated = await adapter.editComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.commentId, - req.version, - req.body, - ); - // 清 cache + 广播,UI 重拉刷新 (跟 delete 同套链路)。返回 updated 仅作 - // 调用方乐观参考 — 实际页面渲染走 cache→force-refresh 路径 - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - return updated; - }, - ); - - ipcMain.handle( - 'comments:fetchAttachment', - async ( - _evt, - req: IpcChannels['comments:fetchAttachment']['request'], - ): Promise => { - // 找 PR 对应的 connection adapter 拉 attachment。不缓存 — 评论图片重复 - // 加载概率低 (用户决策),每次进入 PR 走 IPC 跟头像走 cache 不同 - try { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) return null; - // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL - const res = await adapter.getAttachment(req.url, pr.repo); - if (!res) return null; - const base64 = Buffer.from(res.bytes).toString('base64'); - return { dataUrl: `data:${res.contentType};base64,${base64}` }; - } catch { - return null; - } - }, - ); - - ipcMain.handle( - 'app:userAvatar', - async ( - _evt, - req: IpcChannels['app:userAvatar']['request'], - ): Promise => { - const memKey = `${req.connectionId}|${req.slug}`; - if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; - - const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); - const filePath = path.join(avatarDir, `${hash}.bin`); - - // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) - try { - const stat = await fs.stat(filePath); - const age = Date.now() - stat.mtimeMs; - if (age < AVATAR_TTL_MS) { - const bytes = await fs.readFile(filePath); - const contentType = sniffImageContentType(bytes); - const result = { - dataUrl: `data:${contentType};base64,${bytes.toString('base64')}`, - }; - avatarMem.set(memKey, result); - return result; - } - // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) - await fs.unlink(filePath).catch(() => undefined); - } catch { - // 文件不存在 / 读失败 → 走 fetch - } - - // 2) 没缓存 / 已过期:去 Bitbucket 拉 - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === req.connectionId, - )?.adapter; - if (!adapter) { - avatarMem.set(memKey, null); - return null; - } - try { - const img = await adapter.getUserAvatar(req.slug, req.avatarUrl); - if (!img) { - logger.debug( - { connectionId: req.connectionId, slug: req.slug }, - 'avatar fetch returned null', - ); - avatarMem.set(memKey, null); - return null; - } - // 落盘:best-effort,写失败不影响响应 - try { - await fs.mkdir(avatarDir, { recursive: true }); - await fs.writeFile(filePath, img.bytes); - } catch (writeErr) { - logger.warn({ err: writeErr, hash }, 'avatar disk write failed'); - } - const base64 = Buffer.from(img.bytes).toString('base64'); - const result = { dataUrl: `data:${img.contentType};base64,${base64}` }; - avatarMem.set(memKey, result); - logger.debug( - { - hash, - slug: req.slug, - bytes: img.bytes.length, - contentType: img.contentType, - }, - 'avatar fetched + cached to disk', - ); - return result; - } catch (err) { - logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw'); - avatarMem.set(memKey, null); - return null; - } - }, - ); - ipcMain.handle('config:read', (): IpcChannels['config:read']['response'] => bootstrap.config); - ipcMain.handle('app:openConfigFile', async (): Promise => { - const err = await shell.openPath(bootstrap.paths.configFile); - if (err) throw new Error(`failed to open config.yaml: ${err}`); - }); - ipcMain.handle('app:openAgentDir', async (): Promise => { - // 当前生效的 Agent 目录(用户配置优先,否则默认 ~/.code-meeseeks/agent);先确保存在再打开。 - const dir = effectiveAgentDir(); - await fs.mkdir(dir, { recursive: true }).catch(() => undefined); - const err = await shell.openPath(dir); - if (err) throw new Error(`failed to open agent dir: ${err}`); - }); - ipcMain.handle('app:openDevTools', (evt) => { - evt.sender.openDevTools({ mode: 'detach' }); - }); - ipcMain.handle('app:checkUpdate', (): Promise => { - // 与启动检测一致受 check_enabled 控制:关闭时不发起请求,直接返回禁用结果。 - if (!bootstrap.config.update.check_enabled) { - return Promise.resolve({ - ok: false, - hasUpdate: false, - currentVersion: app.getVersion(), - error: 'update check disabled by config', - }); - } - return checkForUpdate(app.getVersion(), bootstrap.config.proxy); - }); - ipcMain.handle( - 'app:openExternal', - async (_evt, req: IpcChannels['app:openExternal']['request']): Promise => { - // 白名单:仅放行 http(s),防止 file:// / javascript: 等被恶意 markdown 注入触发 - if (!/^https?:\/\//.test(req.url)) return; - await shell.openExternal(req.url); - }, - ); - - ipcMain.handle( - 'dialog:pickDirectory', - async ( - evt, - req: IpcChannels['dialog:pickDirectory']['request'], - ): Promise => { - const win = BrowserWindow.fromWebContents(evt.sender) ?? undefined; - const result = win - ? await dialog.showOpenDialog(win, { - title: req.title ?? t('dialog.selectDirectory'), - defaultPath: req.defaultPath, - properties: ['openDirectory', 'createDirectory'], - }) - : await dialog.showOpenDialog({ - title: req.title ?? t('dialog.selectDirectory'), - defaultPath: req.defaultPath, - properties: ['openDirectory', 'createDirectory'], - }); - if (result.canceled || result.filePaths.length === 0) { - return { path: null }; - } - return { path: result.filePaths[0]! }; - }, - ); - - ipcMain.handle('prs:list', async (): Promise => { - // 单活动连接模型:只展示当前活动连接的 PR。状态库可能仍存着切换前其他连接的 - // 历史 PR(poller 只轮询活动连接,不会清理旧的),故在出口按 connectionId 过滤。 - const activeId = bootstrap.config.active_connection_id; - const all = await listStoredPullRequests(stateStore); - return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; - }); - ipcMain.handle( - 'prs:refresh', - async (): Promise => poller.tick(), - ); - ipcMain.handle('prs:lastSync', (): IpcChannels['prs:lastSync']['response'] => ({ - at: poller.getLastPollAt(), - })); - ipcMain.handle( - 'prs:setLocalStatus', - async ( - _evt, - req: IpcChannels['prs:setLocalStatus']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // 先写远端:本地 status → Bitbucket reviewer.status;失败抛出,前端不会看到本地变更 - const remoteStatus = - req.status === 'approved' - ? 'approved' - : req.status === 'needs_work' - ? 'needsWork' - : 'unapproved'; - await adapter.setPullRequestReviewStatus( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - remoteStatus, - ); - // 远端 OK 后落本地,UI 立即反映;下一轮 poll 会取回相同值 - return setLocalStatus(stateStore, req.localId, req.status); - }, - ); - - ipcMain.handle( - 'prs:merge', - async (_evt, req: IpcChannels['prs:merge']['request']): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // 合并远端;失败 (冲突 / veto / 权限) 抛出,renderer 提示,本地不变。 - // 成功后不在此落本地:PR 转 MERGED 会从 pending 消失,靠 renderer 触发的 - // refresh → poll 软删收尾,避免本地状态与远端各执一词 - await adapter.mergePullRequest( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ); - }, - ); - - /** - * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha - * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR - * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 - * - * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 - * - * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 - * 快速路径命中率应该很高。 + const base = createServiceContext(deps); + // run 队列:pragent:run(PR 域)、Agent 编排、AutoPilot 三方共用。 + const runQueue = new RunQueue(base); + // Agent 编排:复用 run 队列派发工具 run(agent 低优先级泳道)。 + const orchestrator = new Orchestrator(base, runQueue); + // controller 层统一上下文:基础上下文 + 两个跨域 service,安装为进程级单例(controller 经 getContext() 取用)。 + const ctx: ControllerContext = { ...base, runQueue, orchestrator }; + setControllerContext(ctx); + + /* + * GUI 框架交互 + * 应用信息 / 窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 */ - const ensureMirrorReadyForPr = async ( - pr: StoredPullRequest, - ): Promise<{ mirrorPath: string; freshClone: boolean }> => { - const id = repoIdentityFor(pr); - const [hasHead, hasBase] = await Promise.all([ - repoMirror.hasCommit(id, pr.sourceRef.sha), - repoMirror.hasCommit(id, pr.targetRef.sha), - ]); - if (hasHead && hasBase) { - // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log - return { mirrorPath: repoMirror.mirrorPath(id), freshClone: false }; - } - const r = await repoMirror.syncMirror(id); - return { mirrorPath: r.mirrorPath, freshClone: r.freshClone }; - }; - - /** - * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 - * - * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, - * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs//diff-base.json`, - * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: - * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; - * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 - * - * 失效重算:固化 base 不再是当前 head 的祖先(源分支被 rebase)→ 重算。head 正常 push(仅前进) - * 不失效。算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 - * - * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 + ipcMain.handle('app:info', app.readAppInfo); // 应用 / 运行时版本信息(关于页) + ipcMain.handle('app:paths', app.readAppPaths); // 关键目录路径(config / agent / 日志) + ipcMain.handle('app:prAgentStatus', app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) + ipcMain.handle('log:write', app.writeRendererLog); // 渲染层日志回传落盘 + ipcMain.handle('app:connections', app.listConnections); // 当前活动连接摘要(Header / 状态栏) + ipcMain.handle('app:userAvatar', app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) + ipcMain.handle('app:openConfigFile', app.openConfigFile); // 打开 config.yaml + ipcMain.handle('app:openAgentDir', app.openAgentDir); // 打开 Agent 目录 + ipcMain.handle('app:openDevTools', app.openDevTools); // 打开 DevTools(分离窗口) + ipcMain.handle('app:checkUpdate', app.checkUpdate); // 手动检查更新 + ipcMain.handle('app:getUpdateStatus', app.getUpdateStatus); // 读缓存的更新检测结果(水合) + ipcMain.handle('app:openExternal', app.openExternal); // 系统浏览器打开外链 + ipcMain.handle('dialog:pickDirectory', app.pickDirectory); // 原生目录选择对话框 + + /* + * PR 操作 + * 评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 */ - const resolveDiffBaseSha = async (pr: StoredPullRequest): Promise => { - const id = repoIdentityFor(pr); - const head = pr.sourceRef.sha; - const cached = await readDiffBaseCache(stateStore, pr.localId); - if (cached?.base_sha && (await repoMirror.isAncestor(id, cached.base_sha, head))) { - return cached.base_sha; - } - const mb = await repoMirror.mergeBase(id, pr.targetRef.sha, head); - if (!mb) return pr.targetRef.sha; - await writeDiffBaseCache(stateStore, pr.localId, { - base_sha: mb, - head_sha: head, - computed_at: new Date().toISOString(), - }); - return mb; - }; - - ipcMain.handle( - 'repo:sync', - async ( - _evt, - req: IpcChannels['repo:sync']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - return ensureMirrorReadyForPr(pr); - }, - ); - - ipcMain.handle( - 'diff:listChangedFiles', - async ( - _evt, - req: IpcChannels['diff:listChangedFiles']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 自动确保 mirror 含 head + base sha (快速路径命中即 noop);再算 diff - await ensureMirrorReadyForPr(pr); - // base 锚到固定 merge-base(非漂移的 targetRef.sha),三点 diff 对目标分支前移稳定 - const base = await resolveDiffBaseSha(pr); - return repoMirror.listChangedFiles(id, base, pr.sourceRef.sha); - }, - ); - - ipcMain.handle( - 'diff:getFileContent', - async ( - _evt, - req: IpcChannels['diff:getFileContent']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // base 侧读固定 merge-base 的内容(与三点 diff 一致),head 侧读源 tip - const sha = req.side === 'base' ? await resolveDiffBaseSha(pr) : pr.sourceRef.sha; - return repoMirror.getFileContent(id, sha, req.path); - }, - ); - - ipcMain.handle( - 'diff:commentCountCached', - async ( - _evt, - req: IpcChannels['diff:commentCountCached']['request'], - ): Promise => { - const cache = await readCommentsCache(stateStore, req.localId); - if (!cache) return null; - return { count: cache.comments.length }; - }, - ); - - // In-flight dedup: 打开 PR 时 MainPane / DiffView / CommentsPanel 三个组件 - // 并行调 listComments(force:true),没去重的话会打 3 次 Bitbucket API。同一 localId - // 的 concurrent 调用合并到同一个 Promise,远端只打一次 - const listCommentsInFlight = new Map>(); - ipcMain.handle( - 'diff:listComments', - async ( - _evt, - req: IpcChannels['diff:listComments']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - // 缓存命中条件:pr_updated_at 跟当前 PR meta updatedAt 一致 → 直接回缓存, - // 不打远端。PR 任何变更 (新评论 / 状态等) Bitbucket 都会更新 updatedAt,跳变即重拉。 - // - // **req.force=true** 跳过 cache 直接打远端 — 本地 PR.updatedAt 来自 poller - // 周期拉,可能滞后,stale 比对会误判命中。打开 PR 时 renderer 传 force=true - // 强制刷新,确保拿到最新评论 - const cache = await readCommentsCache(stateStore, pr.localId); - if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { - return cache.comments; - } - // dedup:同 localId 的 in-flight Promise 直接复用 - const existing = listCommentsInFlight.get(pr.localId); - if (existing) return existing; - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - const fetchPromise = adapter - .listPullRequestComments( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ) - .then((raw) => annotateOwnership(raw, adapter)) - .then(async (fresh) => { - await writeCommentsCache(stateStore, pr.localId, { - comments: fresh, - pr_updated_at: pr.updatedAt, - fetched_at: new Date().toISOString(), - }); - return fresh; - }) - .finally(() => { - listCommentsInFlight.delete(pr.localId); - }); - listCommentsInFlight.set(pr.localId, fetchPromise); - return fetchPromise; - }, - ); - - ipcMain.handle( - 'diff:listCommits', - async ( - _evt, - req: IpcChannels['diff:listCommits']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // commits 不缓存(量少 + UI 进 commits 标签页才拉,频率低);后续如发现频繁拉 - // 再补 prs//commits.json 缓存层 (走 pr_updated_at 失效,跟 comments 同模式) - return adapter.listPullRequestCommits( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ); - }, - ); - - ipcMain.handle( - 'diff:commitCount', - async ( - _evt, - req: IpcChannels['diff:commitCount']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 本地 git 算提交数;不打远端、不主动触发 sync。镜像还没拉齐就返回 null, - // UI 角标暂不显示,等下次 poll 触发 syncMirror 完成后自然命中。 - // 口径 = PR 自身提交(源分支「不在目标分支上」的非 merge 提交),对齐平台 /commits 列表。 - // **基准用目标分支 sha(head ^target)而非固定 merge-base**:源分支把目标分支(如 dev)合入自己后, - // merge-base 之后被带进来的目标提交也可达 head、不可达 merge-base → 用 merge-base 会把它们误计 - // (标 31 实则 2)。以 targetRef.sha 排除这些合入提交;merge 提交本身由 countCommits 的 --no-merges 略去。 - // (diff 仍用固定 merge-base 保稳定,与本计数口径各司其职。) - const n = await repoMirror.countCommits(id, pr.targetRef.sha, pr.sourceRef.sha); - return n === null ? null : { count: n }; - }, - ); - - ipcMain.handle( - 'diff:getBlame', - async ( - _evt, - req: IpcChannels['diff:getBlame']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 只对 base 已有部分展示 blame;PR 引入的行单独返给 renderer, - // 由 BlameColumn 画色带占位(对应 Monaco diff 添加/修改区的视觉)。 - const base = await resolveDiffBaseSha(pr); - const [allBlame, changedSet] = await Promise.all([ - repoMirror.getBlame(id, pr.sourceRef.sha, req.path), - repoMirror.listChangedHeadLines(id, base, pr.sourceRef.sha, req.path), - ]); - return { - lines: allBlame.filter((b) => !changedSet.has(b.line)), - changedLines: Array.from(changedSet).sort((a, b) => a - b), - }; - }, - ); - - ipcMain.handle('repo:getTotalSize', async (): Promise<{ totalBytes: number }> => { - const prs = await listStoredPullRequests(stateStore); - const seen = new Set(); - let total = 0; - for (const pr of prs) { - let id: RepoIdentity; - try { - id = repoIdentityFor(pr); - } catch { - continue; - } - const key = `${id.host}|${id.projectKey}|${id.repoSlug}`; - if (seen.has(key)) continue; - seen.add(key); - const r = await repoMirror.getSize(id); - total += r.totalBytes; - } - return { totalBytes: total }; - }); - - /** - * 真正执行一个 queue item:startReviewRun → worktree → bridge.run → finishWith。 - * 由 runNext() 调用,签名稳定后跟 queue 主体解耦;任何抛错都被 runNext 兜成 - * Promise reject,外层 pragent:run 调用方收到。 - */ - const executeRun = async (item: QueueItem): Promise => { - const prAgentBridge = getPrAgentBridge(); - if (!prAgentBridge) throw new Error(t('prAgent.notReady')); - const { req, pr } = item; - // 提前 resolve active LLM profile — model 字段要随 startReviewRun 一起落 - // 盘,让 UI 在 meta 行展示"这次 run 用的什么模型"。后面 buildPragentEnv - // 同样会用到,这里 resolve 一次复用 - const activeLlmForRecord = resolveActiveLlmProfile(bootstrap.config.llm); - // 用入队预分配的 runId 覆盖 startReviewRun 的自生 id,让 cancel(runId) 在 active - // 状态也能精确定位 (跟入队时给的 runId 一致) - const run = await startReviewRun(stateStore, { - id: item.info.runId, - prLocalId: pr.localId, - tool: req.tool, - question: req.tool === 'ask' ? req.question : undefined, - prAgentVersion: prAgentBridge.version, - strategy: prAgentBridge.strategy, - // 持久化用 profile.model 原文,不做 normalizeModel 前缀处理 — 跟用户 - // Settings 里看到的名字一致更直观 - model: activeLlmForRecord?.model || undefined, - }); - // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播 - item.info = { ...item.info, startedAt: run.startedAt }; - broadcastQueueChanged(); - logger.info( - { runId: run.id, localId: pr.localId, tool: req.tool, strategy: prAgentBridge.strategy }, - 'pragent run start', - ); - const t0 = Date.now(); - // 真实 token 用量累加器:sitecustomize 的 litellm callback 把每次调用的 usage 以 - // `@@MEEBOX_USAGE@@ {json}` 哨兵行打到 stderr,下面 onLine 拦截累加(无需临时文件 / env)。 - const usageAcc = { prompt: 0, completion: 0, total: 0, calls: 0, any: false }; - const onLine = (line: string, stream: 'stdout' | 'stderr'): void => { - // 拦截 usage 哨兵行:累加后不转发给 renderer(避免污染实时日志)。 - if (stream === 'stderr' && accumulateUsageSentinel(line, usageAcc)) return; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('pragent:runProgress', { runId: run.id, line, stream }); - } - }; - - const finishWith = async (patch: Parameters[3]): Promise => { - const updated = await finishReviewRun(stateStore, pr.localId, run.id, patch); - return updated ?? { ...run, ...patch }; - }; - - const repoId = repoIdentityFor(pr); - await repoMirror.syncMirror(repoId); - // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base(与 UI diff 同源):让 AI 评审基于 - // 「PR 自分叉后引入的改动」,而非 targetRef.sha 漂移后混入别的 PR 的两点对比 - const diffBase = await resolveDiffBaseSha(pr); - const wt = await repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); - const ac = item.ac!; - try { - const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); - // LLM env + 全局 pr-agent 配置 (响应语言)。语言配置一期写死在 config 里, - // UI 还不暴露切换;后续多语言时改成 Settings 入口 - const env: Record = { - // 代理 env 先铺底,LLM/语言配置在后(互不冲突,仅 HTTP(S)_PROXY 类)。 - // 开关开时让嵌入式 python(litellm/httpx) 经代理出网调 LLM。 - ...buildProxyEnv(bootstrap.config.proxy), - ...(activeLlm ? buildPragentEnv(activeLlm) : {}), - CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), - }; - if (req.tool === 'improve') { - // /improve 在 local provider 下只有「汇总建议 → publish_comment」一条可用路径 - // (shim 已强制 gfm_markdown=True)。committable/inline 模式会走 - // publish_code_suggestions → local provider 直接 NotImplementedError,显式关死兜底 - // (pr-agent 默认即 false,此处防上游翻默认值)。 - env['PR_CODE_SUGGESTIONS__COMMITABLE_CODE_SUGGESTIONS'] = 'false'; - // persistent_comment(默认 true)会走 publish_persistent_comment_with_history → - // get_issue_comments() 翻历史评论做增量更新 → local provider 不实现,每次 improve - // 都刷一段 NotImplementedError traceback(被上游捕获后兜底 publish_comment,正文 - // 不丢但日志吵)。local 每次都是全新 worktree、无历史可翻,直接关掉走 publish_comment。 - env['PR_CODE_SUGGESTIONS__PERSISTENT_COMMENT'] = 'false'; - // 输出与 /review /ask 的 review.md 分流:pr-agent 原生支持 local.review_path 覆盖 - // publish_comment 的落盘路径;相对路径按子进程 cwd(= worktree 根)解析。 - env['LOCAL__REVIEW_PATH'] = 'improve.md'; - } - - // 注给 pr-agent 的 EXTRA_INSTRUCTIONS 由三部分按顺序拼接: - // 1. 语言指示:CONFIG__RESPONSE_LANGUAGE 对 /describe /review 够用,但 - // /ask 走 [pr_questions] 配置段不那么严格遵守,必须显式 prompt 强化 - // 2. PR 上下文 (title / description / 已有评论):local provider 自己不会 - // 去 Bitbucket 拉这些,必须我们这边喂;让 /describe /review 不只是看 diff - // 3. 规则正文 (rules.dir 命中):项目编码规约 - // /ask 只取 1 (语言),跳 2/3 (用户问题往往跟历史评论 / 规约无关) - const langDirective = languageDirectiveFor(getMainLanguage()); - let prContext = ''; - let matchedRuleInstructions = ''; - let matchedRuleId: string | undefined; - if (req.tool !== 'ask') { - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (adapter) { - try { - prContext = await buildPrContext({ pr, adapter, logger }); - } catch (err) { - logger.warn( - { err, runId: run.id, localId: pr.localId }, - 'buildPrContext threw; proceeding without PR context', - ); - } - } - - const rules = await loadAgentRules(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), - }); - const matched = pickMatchingRule(rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: req.tool, - }); - if (matched) { - matchedRuleInstructions = matched.instructions; - matchedRuleId = matched.id; - } - } - - // anchor marker 指令:让 model 在涉及代码位置的内容末尾显式追加 - // [file: , lines: -] - // - // 主路径已改为 sitecustomize 注入 LocalGitProvider.get_line_link → key_issues 渲染成 - // `[**header**](meebox:///#L-L)`,parse-output 取结构化 anchor(path 来自 - // provider 同源、最可靠)。但 #L 行号仍依赖 model 填了 pr-agent 原生 start_line/ - // end_line YAML 字段;实测部分模型只填这条 marker、留空结构化字段 → 链接只有 path。 - // 故这条 marker 作为**行号兜底**保留:parse-output 合并时链接给 path、缺行号则用 marker - // 的行号补(resolveIssueAnchor)。两路信号都用上,最大化 anchor 覆盖。 - // - // 两种工具措辞不同: - // - /review: 每条 key_issue 末尾 **必加** marker - // - /ask: 仅当回答涉及具体文件 / 代码位置时 **才加** (自由问答可能完全跟代码 - // 无关 e.g. "PR 概述"),强制会产出假阳性 - // - // /describe / /improve 不注入:前者不出 issue,后者走 marker 行 - // `[file [start-end]](url)` 自己有 anchor - const reviewAnchorDirective = - req.tool === 'review' - ? [ - 'When writing each item under `key_issues_to_review`, append on its OWN LAST LINE', - 'a machine-readable anchor marker in this EXACT format:', - '', - ' [file: , lines: -]', - '', - 'Examples:', - ' [file: src/auth/login.ts, lines: 42-50]', - ' [file: pkg/cache.go, lines: 17]', - '', - 'Use the exact relevant_file path and start_line/end_line you already', - 'identified in the YAML output. Do NOT wrap the path in backticks. If you', - 'truly cannot identify a file/line for an issue, omit the marker for that', - 'item only.', - ].join('\n') - : req.tool === 'ask' - ? [ - 'CRITICAL: This answer is consumed by a code review GUI that converts your', - 'per-paragraph recommendations into INLINE COMMENTS pinned to specific code', - 'lines. For that to work, EVERY paragraph that names a code symbol (function,', - 'method, class, variable, identifier) from this PR MUST end with a', - 'machine-readable anchor marker on its OWN LAST LINE:', - '', - ' [file: , lines: -]', - '', - 'Examples:', - ' [file: src/auth/login.ts, lines: 42-50]', - ' [file: pkg/cache.go, lines: 17]', - ' [file: pkg/store.ts] (path-only fallback; only when you', - ' truly cannot infer any line number)', - '', - 'How to derive line numbers from the diff:', - '- Every hunk in the diff begins with a header:', - ' @@ -, +, @@', - ' The number after `+` is the FIRST head-side line of that hunk. Count down', - ' through `+` (added) and ` ` (context) lines — DO NOT count `-` (removed)', - ' lines — to locate the line where the symbol appears. Prefer head-side', - ' line numbers. For code that ONLY exists on the base side (purely removed),', - ' use the base-side `-` line number instead.', - '', - 'Rules — read carefully:', - '- The marker is REQUIRED. Do not skip it when your paragraph references a', - ' real code symbol from the diff. A paragraph without a marker becomes', - ' un-pinnable feedback the user cannot turn into a comment.', - '- Append exactly ONE marker per paragraph, at the very end of that paragraph,', - ' on its own line (blank line above it optional but recommended).', - '- If a paragraph discusses multiple locations, pick the most important one', - ' (the line where the recommended change should be made).', - '- Paragraphs that are purely general / conceptual / meta (e.g., overall', - ' praise, no specific symbol named) MAY omit the marker.', - '- Use the exact file path from the diff. Do NOT wrap the path in backticks', - ' or quotes inside the marker.', - '- If you really cannot pin a line, fall back to path-only `[file: ]`', - ' rather than omitting the marker entirely.', - ].join('\n') - : ''; - - // 排版指令:只改 /review 每条 key_issue 的断行排版,提升 GUI 可读性,不增加篇幅。 - // pr-agent 原 prompt 要 "short and concise summary",模型默认堆成单段长跑文; - // 渲染层 (ReactMarkdown + remarkBreaks) 忠实呈现,空行分段即成独立 ); } - - // gate 条件 = 有无「有效的 active 连接」:连接为空 / active 悬空都触发首启向导。 - // 不依赖一次性 firstRun 标记 —— 用户清空连接后下次进入仍会回到向导。 - const needsOnboarding = - forceOnboarding || - !boot.config.connections.some((c) => c.id === boot.config.active_connection_id); if (needsOnboarding) { return ( c.connectionId === selected.connectionId) + : undefined; // 有 active 连接但 LLM 未配置 → ChatPane 给出「需配置才能启用」提示并禁用输入 const llmConfigured = boot.config.llm.profiles.some((p) => p.id === boot.config.llm.active_id); - // 发现分类标签由活动连接的能力决定(GitHub 四类、Bitbucket 两类、其余无)。 const activeConnSummary = boot.connections.find( (c) => c.connectionId === boot.config.active_connection_id, ); const availableDiscoveryFilters = activeConnSummary?.capabilities.discoveryFilters ?? []; const showDiscoveryFilter = availableDiscoveryFilters.length > 0; - // 选中的分类可能因切换连接而对当前平台无效(如 github 的 mentioned 切到 bitbucket)→ 回落首个可用。 + // 选中的分类可能因切换连接而对当前平台无效 → 回落首个可用。 const effectiveDiscoveryFilter = availableDiscoveryFilters.includes(discoveryFilter) ? discoveryFilter : availableDiscoveryFilters[0]; @@ -423,21 +123,24 @@ export default function App() { onDiscoveryFilterChange={showDiscoveryFilter ? setDiscoveryFilter : undefined} /> )} - 0} - onSetStatus={(s) => void setSelectedPrStatus(s)} - onMerge={() => void mergeSelectedPr()} - merging={merging} - capabilities={selectedConn?.capabilities} - currentUserName={selectedConn?.user?.name ?? null} - pendingDiffNav={pendingDiffNav} - onDiffNavConsumed={() => setPendingDiffNav(null)} - onRequestDiffNav={(target) => setPendingDiffNav(target)} - /> - {/* ChatPane 始终挂载,折叠只是 CSS 隐藏:保住运行中的 run 生命周期。 - 如果走条件渲染,折叠 = 卸载组件,进行中的计时器 / runProgress 订阅 - 全丢,再展开只能从持久化里看到已完成的结果 */} + + {selected ? ( + void setSelectedPrStatus(s)} + onMerge={() => void mergeSelectedPr()} + merging={merging} + capabilities={selectedConn?.capabilities} + currentUserName={selectedConn?.user?.name ?? null} + pendingDiffNav={pendingDiffNav} + onDiffNavConsumed={() => setPendingDiffNav(null)} + onRequestDiffNav={(target) => setPendingDiffNav(target)} + /> + ) : ( + 0} /> + )} + + {/* ChatPane 始终挂载,折叠只是 CSS 隐藏:保住运行中的 run 生命周期(计时器 / runProgress 订阅)。 */} setShowSettings(true)} - onJumpToDraftEditor={(t) => setPendingDiffNav(t)} + onJumpToDraftEditor={(target) => setPendingDiffNav(target)} onNavigateToAnchor={(anchor) => setPendingDiffNav({ anchor })} onSetReviewStatus={(s) => void setSelectedPrStatus(s)} - // 当前 active LLM profile.model — RunningView 显示成 chip 让用户知道 - // 这次 review 用的什么模型 (不同 profile 出的结果差异大) currentLlmModel={ boot.config.llm.profiles.find((p) => p.id === boot.config.llm.active_id)?.model ?? null } @@ -473,7 +173,7 @@ export default function App() { onSwitchActiveLlm={(id) => { const next = { ...boot.config.llm, active_id: id }; void invoke('config:setLlm', { llm: next }); - setBoot((b) => (b ? { ...b, config: { ...b.config, llm: next } } : b)); + patchConfig((c) => ({ ...c, llm: next })); }} onJumpToPr={setSelectedId} updateInfo={updateInfo} @@ -481,20 +181,10 @@ export default function App() { onToggleAutopilot={() => { const enabled = !boot.config.agent.autopilot.enabled; void invoke('agent:setAutopilotEnabled', { enabled }); - setBoot((b) => - b - ? { - ...b, - config: { - ...b.config, - agent: { - ...b.config.agent, - autopilot: { ...b.config.agent.autopilot, enabled }, - }, - }, - } - : b, - ); + patchConfig((c) => ({ + ...c, + agent: { ...c.agent, autopilot: { ...c.agent.autopilot, enabled } }, + })); }} /> {showSettings && ( @@ -502,13 +192,9 @@ export default function App() { info={boot.info} paths={boot.paths} config={boot.config} - onLlmChange={(llm) => setBoot((b) => (b ? { ...b, config: { ...b.config, llm } } : b))} - onProxyChange={(proxy) => - setBoot((b) => (b ? { ...b, config: { ...b.config, proxy } } : b)) - } - onLanguageChange={(language) => - setBoot((b) => (b ? { ...b, config: { ...b.config, language } } : b)) - } + onLlmChange={(llm) => patchConfig((c) => ({ ...c, llm }))} + onProxyChange={(proxy) => patchConfig((c) => ({ ...c, proxy }))} + onLanguageChange={(language) => patchConfig((c) => ({ ...c, language }))} onConnectionsChange={refreshBootAndPrs} onClose={() => setShowSettings(false)} /> @@ -517,7 +203,7 @@ export default function App() {
setToast(null)} + onClick={dismissToast} title={t('app.toastCloseTitle')} > {toast.text} diff --git a/apps/desktop/src/renderer/src/api.ts b/apps/desktop/src/renderer/src/api.ts index 8f526381..123fb954 100644 --- a/apps/desktop/src/renderer/src/api.ts +++ b/apps/desktop/src/renderer/src/api.ts @@ -1,4 +1,4 @@ -import type { IpcChannelName, IpcChannels, IpcEventName, IpcEvents } from '@meebox/shared'; +import type { IpcChannelName, IpcChannels, IpcEventName, IpcEvents } from '@meebox/ipc'; export function invoke( channel: K, diff --git a/apps/desktop/src/renderer/src/components/ChatPane.tsx b/apps/desktop/src/renderer/src/components/ChatPane.tsx deleted file mode 100644 index f9e42ea2..00000000 --- a/apps/desktop/src/renderer/src/components/ChatPane.tsx +++ /dev/null @@ -1,2578 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import type { TFunction } from 'i18next'; -import ReactMarkdown from 'react-markdown'; -import remarkBreaks from 'remark-breaks'; -import remarkGfm from 'remark-gfm'; -import type { - AgentMessage, - AgentStep, - Finding, - IpcChannels, - LocalPrStatus, - PrAgentStatus, - PrDocSectionKey, - ReviewRun, - ReviewRunTool, - StoredPullRequest, -} from '@meebox/shared'; - -type MatchedRule = IpcChannels['rules:matchForPr']['response']; -import type { ReviewDraft } from '@meebox/shared'; -import { invoke, subscribe } from '../api'; -import { - AutoReviewIcon, - ChatIcon, - ChevronIcon, - CloseIcon, - QuestionIcon, - RetryIcon, - RobotIcon, - SendIcon, - StopIcon, - TrashIcon, -} from './icons'; -import { ConfirmModal } from './ConfirmModal'; -import { PaneLoading } from './Loading'; -import { mermaidComponents, walkthroughMdComponents } from './markdownMermaid'; -import { REMOTE_REHYPE_PLUGINS } from '../markdown'; -import { useChatRunStore } from '../stores/chat-run-store'; -import { useDraftsForPr } from '../stores/drafts-store'; -import { parseAnsi, segmentStyle } from '../utils/ansi'; -import { translatePrAgentLabels } from '../utils/translate-pr-agent'; - -export const CHAT_MIN_WIDTH = 280; -export const CHAT_MAX_WIDTH = 720; -/** 历史 run 的分页大小:进入 PR 默认展示最新 N 条,向上滚动到顶端再追加一批 */ -const RUNS_PAGE_SIZE = 10; - -/** Agent 建议 verdict → i18n key(chatPane.agent.*)。 */ -const VERDICT_LABEL_KEY: Record = { - approve: 'chatPane.agent.verdictApprove', - needs_work: 'chatPane.agent.verdictNeedsWork', - manual_review: 'chatPane.agent.verdictManualReview', -}; - -interface ChatPaneProps { - pr: StoredPullRequest | null; - prAgent: PrAgentStatus; - width: number; - onResize: (next: number) => void; - /** 折叠时仍然挂载组件 (保住进行中的 run 计时器 / runProgress 订阅), - 只用 CSS 隐藏。展开后用户看到的就是当前实时状态 */ - collapsed?: boolean; - /** - * 跳到 Diff 视图编辑某条 finding 对应的草稿 (M4)。父组件 (MainPane) - * 实现:切 tab='diff' + DiffView scroll/highlight/open edit zone + 懒创建 draft - * 如果还没有。anchor 已由 finding.anchor 直接给到。 - */ - onJumpToDraftEditor?: (target: { - runId: string; - findingId: string; - anchor: { path: string; startLine: number; endLine: number }; - }) => void; - /** /approve /needswork 命令触发的 PR review 决断;由 MainPane 接到 prs:setLocalStatus */ - onSetReviewStatus?: (status: LocalPrStatus) => void; - /** - * 点击 finding 的文件行锚点 → 仅跳转到 Diff 对应行(scroll+highlight,不进编辑态)。 - * 跟 onJumpToDraftEditor 的区别:不带 runId/findingId,不创建 / 打开草稿。 - */ - onNavigateToAnchor?: (anchor: { path: string; startLine: number; endLine: number }) => void; - /** - * 当前 active LLM profile 的 model 名 — RunningView meta chip 显示。 - * null = 无 active profile / 还在加载,UI 不展示 model chip - */ - currentLlmModel?: string | null; - /** - * 是否已配置可用的 LLM(存在与 active_id 匹配的 profile)。false 时即便 pr-agent - * 运行时就绪,也无法发起调用 —— 空态 / 输入栏给出「需配置」提示并禁用。 - */ - llmConfigured?: boolean; - /** 评审任务并发上限(pr_agent.max_concurrency)。达到后新提交进排队,据此显示提示 */ - maxConcurrency?: number; - /** 打开设置面板(LLM 未配置提示里的「去设置」按钮用) */ - onOpenSettings?: () => void; -} - -/** - * pr-agent 调用面板(M3-D1)。 - * - 头部:两个动作按钮 (/describe /review),pr-agent 不可用时禁用并指引到 Settings - * - 运行中:实时滚动 stdout(main 通过 pragent:runProgress 流式推送) - * - 运行后:展示最新 ReviewRun 的 findings 列表(markdown body + 可选 anchor), - * 并保留 raw stdout 在底部可折叠区,方便诊断 - * - * /ask 自然语言追问留到后续:当前 pr-agent 在多轮交互上没有稳定的本地协议, - * 先把"开始 review → 结果可见"链路打通,覆盖 M3 done-when。 - */ -export function ChatPane({ - pr, - prAgent, - width, - onResize, - collapsed, - onJumpToDraftEditor, - onSetReviewStatus, - onNavigateToAnchor, - currentLlmModel, - llmConfigured = true, - maxConcurrency = 2, - onOpenSettings, -}: ChatPaneProps) { - const { t } = useTranslation(); - const startResize = (e: React.MouseEvent): void => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = width; - // 拖右边 = 缩小 chat (远离左侧的 dx 是正) - const onMove = (ev: MouseEvent): void => { - const dx = ev.clientX - startX; - const next = Math.min(CHAT_MAX_WIDTH, Math.max(CHAT_MIN_WIDTH, startWidth - dx)); - onResize(next); - }; - const onUp = (): void => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }; - - // runs 按 startedAt 升序保存 (chat 习惯:旧在上 / 新在下)。分页:进入 PR 默认拉 - // 最新 RUNS_PAGE_SIZE 条,向上滚到顶端用 runs[0].id 当游标向 main 要更早一批 - const [runs, setRuns] = useState([]); - const [hasMoreOlder, setHasMoreOlder] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - // 切 PR 时初次拉取(runs / 规则 / 会话 / transcript)在飞标志:期间盖延迟 loading, - // 避免「清空 → 空白 → 内容 pop-in」的抖动。延迟显示让快路径(缓存命中)零闪烁。 - const [loadingSession, setLoadingSession] = useState(false); - const [error, setError] = useState(null); - // 当前 PR 命中的规则 (针对 /review 工具;缺省 tools=[review] 是规则最常生效的场景) - const [matchedRule, setMatchedRule] = useState(null); - const [showRulePreview, setShowRulePreview] = useState(false); - // Agent 运行态(自动评审微流程 / 自由规划对话):记录**各 PR** 的起跑时刻(localId → since)。 - // 不再全局单并发——不同 PR 的 agent 任务可并发 / 排队(重活即工具 run 走全局运行队列节流),仅禁止 - // 对**同一 PR**重复发起;运行态 / 思考中指示按发起 PR 归属,不串到其它 PR 会话。 - const [runningPrs, setRunningPrs] = useState>(() => new Map()); - const [agentSteps, setAgentSteps] = useState([]); - // 多轮对话消息(用户输入 + Agent 回答),跨回合保留、由 main 落盘 conversation.json, - // 切回该 PR 恢复。用户消息含临时 optimistic 项(提交即回显),收尾后整体以落盘版重载对齐。 - const [messages, setMessages] = useState([]); - const [showClearConfirm, setShowClearConfirm] = useState(false); - const bodyRef = useRef(null); - // 当前展示中的 PR id(每渲染同步):异步 Agent 任务 resolve 时据此判断是否仍停在发起 PR, - // 避免把收尾结果 / 错误串台到切换后打开的别的 PR 会话。 - const currentPrIdRef = useRef(undefined); - - // 全局活动 run + 实时 stdout 缓存。store 来源于 main 的 'pragent:activeChanged' - // / 'pragent:runProgress' 事件,PR 切换不丢,所以这里只读,不在本组件维护 - const { active, waiting, linesByRunId } = useChatRunStore(); - // 并发模型:active 是多条并发运行中的 run。本 PR 的运行中 run 可能 >1(用户对同一 - // PR 连发多个工具);其它 PR 的并发数用于「别处在跑」提示。 - const myActiveRuns = active.filter((a) => a.prLocalId === pr?.localId); - const hasMyActive = myActiveRuns.length > 0; - // 仅在「触达并发上限」时提示:此时新提交才会真正排队;未达上限即时并发执行,无需提示。 - const concurrencyReached = active.length >= maxConcurrency; - // 本 PR 排队中的任务(FIFO,前面的先跑),在 chat 末尾以「排队中」卡片展示 - const myWaiting = waiting.filter((w) => w.prLocalId === pr?.localId); - - // 切走再回来时,正在跑的 run 已落盘 (status=running) → listRuns 把它读进 runs, - // 同时它又是实时运行中 run,会重复渲染 (历史卡片 + RunningView 各一条)。这里把 - // 所有运行中 run 从历史列表剔除,运行中的展示统一交给下方 RunningView 负责。 - const myActiveIdSet = new Set(myActiveRuns.map((a) => a.runId)); - const visibleRuns = hasMyActive ? runs.filter((r) => !myActiveIdSet.has(r.id)) : runs; - - // 历史时间线:把已完成 run、正在执行的 run、Agent 思考步骤、对话消息统一按**启动时间**归并排序, - // 顺序固定——即便后启动的任务先完成,也排在先启动(仍在执行)的任务下方,不因完成先后跳序。 - // 类 Claude Code「先思考→定步骤→执行步骤」:思考步骤(plan/judge)是工具选择的前因,排在所选工具的 - // run 卡片之前;工具执行的进度 / 计时由 run 卡片承载,不重复。排队中(未启动)的任务不入此列,另置末尾。 - type ActiveRun = (typeof myActiveRuns)[number]; - const timeline = useMemo(() => { - const ms = (iso: string | null | undefined): number => { - const n = iso ? new Date(iso).getTime() : NaN; - return Number.isFinite(n) ? n : 0; - }; - const base = { - run: null as ReviewRun | null, - active: null as ActiveRun | null, - step: null as AgentStep | null, - message: null as AgentMessage | null, - }; - const runEntries = visibleRuns.map((r) => ({ - ...base, - key: `run-${r.id}`, - sortTime: ms(r.startedAt), - run: r as ReviewRun | null, - })); - const activeEntries = myActiveRuns.map((a) => ({ - ...base, - key: `active-${a.runId}`, - sortTime: ms(a.startedAt ?? a.enqueuedAt), - active: a, - })); - const stepEntries = agentSteps.map((s, i) => ({ - ...base, - key: `step-${i}-${s.at ?? ''}`, - sortTime: ms(s.at), - step: s as AgentStep | null, - })); - const msgEntries = messages.map((m, i) => ({ - ...base, - key: `msg-${i}-${m.at}`, - sortTime: ms(m.at), - message: m, - })); - return [...runEntries, ...activeEntries, ...stepEntries, ...msgEntries].sort( - (a, b) => a.sortTime - b.sortTime, - ); - }, [visibleRuns, myActiveRuns, agentSteps, messages]); - - // PR 切换:重置面板状态 + 拉该 PR 的 run 历史 (含切走前还在跑、现在已落盘的 run)。 - // 依赖用 pr?.localId 而不是 pr 对象引用:App 在 poll tick / window focus 时会 - // reloadPrs → 新 prs 数组 → selected 是新对象引用 → 如果依赖 pr,此 effect 重跑, - // 用户输入 / 规则提示等组件状态被清空。localId 是稳定字符串,同 PR 刷新不触发。 - const prLocalId = pr?.localId; - currentPrIdRef.current = prLocalId; - // 仅「跑在当前 PR」才在本会话显示运行态 / 思考中;其它 PR 在跑不影响本会话发起(可并发 / 排队)。 - const agentRunningHere = prLocalId !== undefined && runningPrs.has(prLocalId); - // 「思考中」实时计时的锚点:取「最近一次活动结束」——{本 PR run 起点, 末个思考步 at, 末个完成 run - // 的结束时刻} 三者最晚者。锚到持久数据(runningPrs 跨 PR 切换不清、run 历史会重载)而非组件挂载, - // 故切走再切回不清零;用 run 结束而非步骤记录时刻,避免把工具执行时间算进当前思考。 - const thinkingSince = useMemo(() => { - const cands: number[] = []; - const since = prLocalId !== undefined ? runningPrs.get(prLocalId) : undefined; - if (since !== undefined) cands.push(since); - const lastStepAt = agentSteps[agentSteps.length - 1]?.at; - if (lastStepAt) { - const ms = new Date(lastStepAt).getTime(); - if (Number.isFinite(ms)) cands.push(ms); - } - const lastRun = visibleRuns[visibleRuns.length - 1]; - const lastRunEnd = lastRun?.finishedAt ?? lastRun?.startedAt; - if (lastRunEnd) { - const ms = new Date(lastRunEnd).getTime(); - if (Number.isFinite(ms)) cands.push(ms); - } - return cands.length ? Math.max(...cands) : Date.now(); - }, [runningPrs, prLocalId, agentSteps, visibleRuns]); - useEffect(() => { - setRuns([]); - setHasMoreOlder(false); - setLoadingOlder(false); - setError(null); - setMatchedRule(null); - setAgentSteps([]); - setMessages([]); - setLoadingSession(false); - if (!prLocalId) return; - let cancelled = false; - setLoadingSession(true); - void (async () => { - try { - // listRuns 默认返回 newest-first;这里只拉最新一页 (RUNS_PAGE_SIZE)。 - // 同时拉已落盘的多轮对话 + 过程步骤(transcript):把会话恢复到其 PR,跨切换 / 重启不丢失, - // 过程化跟踪的思考步骤也随之恢复(步骤随产生增量落盘)。 - const [list, rule, conversation, transcript] = await Promise.all([ - invoke('pragent:listRuns', { localId: prLocalId, limit: RUNS_PAGE_SIZE }), - invoke('rules:matchForPr', { localId: prLocalId, tool: 'review' }), - invoke('agent:getConversation', { localId: prLocalId }), - invoke('agent:getTranscript', { localId: prLocalId }), - ]); - if (cancelled) return; - // 反转为升序 (chat 习惯),UI 直接读 runs 即可 - setRuns([...list].reverse()); - setHasMoreOlder(list.length === RUNS_PAGE_SIZE); - setMatchedRule(rule); - setMessages(conversation); - setAgentSteps(transcript); - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); - } finally { - if (!cancelled) setLoadingSession(false); - } - })(); - return () => { - cancelled = true; - }; - }, [prLocalId]); - - // Agent 步骤流式:订阅 main 的 agent:stepProgress,按当前 PR 过滤实时追加。 - useEffect(() => { - if (!prLocalId) return; - return subscribe('agent:stepProgress', (ev) => { - // 流式步骤可能未带 at(编排器广播在落盘 stamp 之前)→ 到达即补一个时间戳, - // 供下方与 run 卡片按时间归并排序(自然时间顺序展示)。 - if (ev.prLocalId === prLocalId) - setAgentSteps((s) => [...s, { ...ev.step, at: ev.step.at ?? new Date().toISOString() }]); - }); - }, [prLocalId]); - - // 后台评审(AutoPilot)收尾追加「评审总结」消息时,若正打开该 PR 则重载会话,让总结卡片即时出现。 - useEffect(() => { - if (!prLocalId) return; - return subscribe('agent:conversationChanged', (ev) => { - if (ev.prLocalId === prLocalId) void reloadConversation(prLocalId); - }); - }, [prLocalId]); - - // 本 PR 的运行中 run 集合发生「移除」→ 那条跑完了:单独 fetch 它 + 按 runId 升序 - // 插入 runs(不重拉整页,避免毁掉用户已向上加载的更早历史)。lines 缓存的回收已 - // 上移到 store 层(setQueue 全局处理),这里不再负责。多并发下逐条 diff 处理。 - const myActiveIds = myActiveRuns.map((a) => a.runId); - const myActiveIdsKey = myActiveIds.join(','); - const prevMyActiveRef = useRef(myActiveIds); - const prevPrRef = useRef(prLocalId); - useEffect(() => { - const prevPr = prevPrRef.current; - prevPrRef.current = prLocalId; - const prev = prevMyActiveRef.current; - prevMyActiveRef.current = myActiveIds; - // PR 切换:prev 属于旧 PR,不能当本 PR 的「跑完」处理,仅同步 ref - if (prevPr !== prLocalId || !prLocalId) return; - const current = new Set(myActiveIds); - for (const runId of prev) { - if (current.has(runId)) continue; - void (async () => { - try { - const finished = await invoke('pragent:getRun', { localId: prLocalId, runId }); - if (finished) { - setRuns((prevRuns) => { - const idx = prevRuns.findIndex((r) => r.id === finished.id); - if (idx >= 0) { - // 已在列表(重复事件 / 重连)→ 就地更新 - const next = prevRuns.slice(); - next[idx] = finished; - return next; - } - // 并发完成顺序 ≠ runId 顺序:按 runId 升序插入而非无条件 append, - // 维持 runs 始终有序(loadOlderRuns 以 runs[0].id 作游标拉更早历史, - // 依赖此不变量)。runId 字典序即时序,可直接字符串比较。 - const insertAt = prevRuns.findIndex((r) => r.id > finished.id); - if (insertAt < 0) return [...prevRuns, finished]; - const next = prevRuns.slice(); - next.splice(insertAt, 0, finished); - return next; - }); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - })(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [myActiveIdsKey, prLocalId]); - - // 向上滚到顶端 → 用 runs[0].id 当游标,向 main 要更早一批,prepend 到 runs。 - // 保留视觉滚动位置:插入新内容后把 scrollTop 推到 (newHeight - prevHeight) - // 抵消,用户看上去像"接着原来位置" - const loadOlderRuns = async (): Promise => { - if (loadingOlder || !hasMoreOlder || !prLocalId || runs.length === 0) return; - setLoadingOlder(true); - const el = bodyRef.current; - const prevHeight = el?.scrollHeight ?? 0; - const prevTop = el?.scrollTop ?? 0; - try { - const older = await invoke('pragent:listRuns', { - localId: prLocalId, - limit: RUNS_PAGE_SIZE, - beforeId: runs[0]!.id, - }); - // older 是 newest-first,反转后整段塞到 runs 前面 - setRuns((prev) => [...[...older].reverse(), ...prev]); - setHasMoreOlder(older.length === RUNS_PAGE_SIZE); - // 下一帧补齐滚动位置 - requestAnimationFrame(() => { - if (!bodyRef.current) return; - bodyRef.current.scrollTop = prevTop + (bodyRef.current.scrollHeight - prevHeight); - }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoadingOlder(false); - } - }; - - // 触发 /describe / /review / /ask。队列模型下 active 非空也允许提交,新 run 进 - // 队列,main 端先后串行执行。失败抛 banner;成功不需要手动 setRuns,下面 effect - // 会在 active 切换时自动 refresh - const handleRun = async (tool: ReviewRunTool, question?: string): Promise => { - if (!pr || !prAgent.available || !llmConfigured) return; - // 去重(即时反馈):同一 PR 同一工具已在执行 / 排队 → 阻止重复触发(main 端亦有 - // 权威校验兜底)。/ask 每次问题不同,不限制。 - if ( - tool !== 'ask' && - (myActiveRuns.some((r) => r.tool === tool) || myWaiting.some((w) => w.tool === tool)) - ) { - setError(t('chatPane.duplicateRun', { tool })); - return; - } - setError(null); - try { - await invoke('pragent:run', { localId: pr.localId, tool, question }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }; - - // 从 main 重载某 PR 的多轮对话(落盘版为准);仅当仍停在该 PR 才落到当前视图,避免串台。 - const reloadConversation = async (localId: string): Promise => { - try { - const conversation = await invoke('agent:getConversation', { localId }); - if (currentPrIdRef.current === localId) setMessages(conversation); - } catch { - /* 忽略:下次 PR 切换 effect 会重载 */ - } - }; - - // 一键自动评审:触发 main 的 agent:run(评审微流程)。describe/review/ask 子 run 经既有运行 - // 队列展示在历史里;收尾评审作为一条 assistant 消息落入多轮对话,完成后重载对话呈现。 - const handleAgentReview = async (): Promise => { - // 仅禁止对同一 PR 重复发起;其它 PR 在跑不阻塞(并发 / 排队)。 - if (!pr || !prAgent.available || !llmConfigured || runningPrs.has(pr.localId)) return; - const startedId = pr.localId; - setError(null); - setAgentSteps([]); - setRunningPrs((m) => new Map(m).set(startedId, Date.now())); - try { - const session = await invoke('agent:run', { localId: startedId }); - await reloadConversation(startedId); - if (currentPrIdRef.current === startedId && session.status === 'failed') { - setError(session.terminationReason ?? t('chatPane.agent.failed')); - } - } catch (e) { - if (currentPrIdRef.current === startedId) { - setError(e instanceof Error ? e.message : String(e)); - } - } finally { - setRunningPrs((m) => { - const next = new Map(m); - next.delete(startedId); - return next; - }); - } - }; - - // 自然语言「对话即委派」:交给自由规划 Agent(agent:ask)。用户输入即时 optimistic 回显, - // 收尾后以落盘对话(含用户 + 助手消息)整体对齐。 - const handleAgentAsk = async (question: string): Promise => { - // 仅禁止对同一 PR 重复发起;其它 PR 在跑不阻塞(并发 / 排队)。 - if (!pr || !prAgent.available || !llmConfigured || runningPrs.has(pr.localId)) return; - const startedId = pr.localId; - setError(null); - setAgentSteps([]); - setMessages((prev) => [ - ...prev, - { role: 'user', content: question, at: new Date().toISOString() }, - ]); - setRunningPrs((m) => new Map(m).set(startedId, Date.now())); - try { - const session = await invoke('agent:ask', { localId: startedId, question }); - await reloadConversation(startedId); - if (currentPrIdRef.current === startedId && session.status === 'failed') { - setError(session.terminationReason ?? t('chatPane.agent.failed')); - } - } catch (e) { - if (currentPrIdRef.current === startedId) { - setError(e instanceof Error ? e.message : String(e)); - } - } finally { - setRunningPrs((m) => { - const next = new Map(m); - next.delete(startedId); - return next; - }); - } - }; - - // 清空当前 PR 的执行历史(仅该 PR):删远端记录 + 清本地列表,并一并清掉 Agent 收尾结果 / - // 步骤 / 错误横幅(含「已停止 / 失败」提示),避免清空后仍残留陈旧反馈。进行中的 run 不受影响 - // (在 chatRunStore,跑完会重新落盘)。 - const handleClearRuns = async (): Promise => { - setShowClearConfirm(false); - if (!prLocalId) return; - try { - await invoke('pragent:clearRuns', { localId: prLocalId }); - setRuns([]); - setHasMoreOlder(false); - setError(null); - setAgentSteps([]); - setMessages([]); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }; - - // 取消 / 重试 在 store 模型里就是简单两步:cancel 走 IPC,retry 调 handleRun - const handleCancel = async (runId: string): Promise => { - try { - await invoke('pragent:cancel', { runId }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }; - // 停止本 PR 会话内进行中的全部任务:逐条取消所有活动 run(Agent 并行多选时可能 >1), - // 并中止 Agent 编排(abort,阻止其在子任务取消后继续后续步骤)。 - const handleStopAll = (): void => { - for (const r of myActiveRuns) void handleCancel(r.runId); - if (agentRunningHere && prLocalId) void invoke('agent:stop', { localId: prLocalId }); - }; - const handleRetry = (run: ReviewRun): void => { - void handleRun(run.tool, run.question); - }; - - // M4 草稿池:从 main 进程拉本 PR 的草稿,跟 finding 通过 source 字段反查关联 - const drafts = useDraftsForPr(prLocalId); - - /** - * ChatPane finding card 上点"编辑"按钮的处理: - * - 已有关联草稿 → 直接 onJumpToDraftEditor,DiffView 打开它 - * - 没有关联草稿 → 懒创建一条 pending + onJumpToDraftEditor - * - 关联草稿是 rejected → update 回 pending (撤销拒绝) + 跳转 - */ - /** - * 把 AI finding body 转成草稿初始 body:先 stripFindingMarker 去掉 [file:...] - * 末尾 marker,再把 pr-agent GFM 里的内联 HTML 标签归一成 markdown(草稿编辑器是 - * 纯文本,裸 ``/`
` 会露馅),最后加 `[AI 建议]` 前缀 — 让远端 reviewer - * 看到时知道这条评论来自 pr-agent - */ - const buildDraftBodyFromFinding = (body: string): string => - `${t('chatPane.aiSuggestionPrefix')} ${htmlInlineToMarkdown(stripFindingMarker(body))}`; - - const handleJumpToDraft = async (finding: Finding, run: ReviewRun): Promise => { - if (!pr) return; - if (!finding.anchor || typeof finding.anchor.startLine !== 'number') { - return; // 没 anchor 行号 → 没法变 inline,按钮本不该出现,兜底 - } - const startLine = finding.anchor.startLine; - const endLine = finding.anchor.endLine ?? startLine; - const existing = (drafts ?? []).find( - (d) => - d.source !== undefined && d.source.runId === run.id && d.source.findingId === finding.id, - ); - try { - if (!existing) { - // 懒创建:从 finding 拷贝 body 作初始内容;side 默认 'new' (head 侧 inline 评论惯例) - await invoke('drafts:create', { - localId: pr.localId, - draft: { - anchor: { path: finding.anchor.path, startLine, endLine, side: 'new' }, - body: buildDraftBodyFromFinding(finding.body), - origin: 'finding', - source: { runId: run.id, findingId: finding.id }, - status: 'pending', - }, - }); - } else if (existing.status === 'rejected') { - // 撤销 reject 决断 → 回到 pending,让用户重新编辑 - await invoke('drafts:update', { - localId: pr.localId, - draftId: existing.id, - patch: { status: 'pending' }, - }); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - return; - } - onJumpToDraftEditor?.({ - runId: run.id, - findingId: finding.id, - anchor: { path: finding.anchor.path, startLine, endLine }, - }); - }; - - // 点击 finding 锚点:仅导航到 Diff 对应行(不创建/打开草稿),便于快速核对上下文 - const handleNavigateToFinding = (finding: Finding): void => { - if (!finding.anchor || typeof finding.anchor.startLine !== 'number') return; - const startLine = finding.anchor.startLine; - onNavigateToAnchor?.({ - path: finding.anchor.path, - startLine, - endLine: finding.anchor.endLine ?? startLine, - }); - }; - - const handleRejectFinding = async (finding: Finding, run: ReviewRun): Promise => { - if (!pr) return; - if (!finding.anchor || typeof finding.anchor.startLine !== 'number') return; - const startLine = finding.anchor.startLine; - const endLine = finding.anchor.endLine ?? startLine; - const existing = (drafts ?? []).find( - (d) => - d.source !== undefined && d.source.runId === run.id && d.source.findingId === finding.id, - ); - try { - if (existing) { - await invoke('drafts:update', { - localId: pr.localId, - draftId: existing.id, - patch: { status: 'rejected' }, - }); - } else { - await invoke('drafts:create', { - localId: pr.localId, - draft: { - anchor: { path: finding.anchor.path, startLine, endLine, side: 'new' }, - body: buildDraftBodyFromFinding(finding.body), - origin: 'finding', - source: { runId: run.id, findingId: finding.id }, - status: 'rejected', - }, - }); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }; - - // 新 run 完成 / 运行中 run 集合变化时自动滚到底,让最新消息浮上来 - useEffect(() => { - const el = bodyRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [runs.length, myActiveIdsKey]); - - // 向上滚到顶端 → 触发 loadOlderRuns 拉更早一批 (cursor = runs[0].id) - useEffect(() => { - const el = bodyRef.current; - if (!el) return; - const onScroll = (): void => { - if (el.scrollTop > 8) return; - void loadOlderRuns(); - }; - el.addEventListener('scroll', onScroll); - return () => el.removeEventListener('scroll', onScroll); - // loadOlderRuns 是稳定的语义包装,依赖项放足够即可 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasMoreOlder, loadingOlder, prLocalId, runs.length]); - - return ( - - ); -} - -/** 槽位定义:键盘操作 / 命令按钮 / 自动补全菜单都从这里取 */ -/** - * Chat 命令分两类: - * - 'pragent': pr-agent 工具 (review / describe / ask),触发 pragent:run - * - 'review-action': PR review 决断 (approve / needswork),写 Bitbucket reviewer status - * 通过 prs:setLocalStatus 触发,跟 PR header 按钮共用同一路径 - */ -type CommandSpec = - | { - kind: 'pragent'; - name: ReviewRunTool; - label: string; - /** i18n key (chatPane 命名空间) 解析命令描述,渲染时用 t(descKey) */ - descKey: string; - insertAs: string; - } - | { - kind: 'review-action'; - name: 'approve' | 'needswork'; - label: string; - /** i18n key (chatPane 命名空间) 解析命令描述,渲染时用 t(descKey) */ - descKey: string; - insertAs: string; - reviewStatus: LocalPrStatus; - }; - -// 分组顺序:pr-agent 工具 → 分隔线 → review 决断 -const COMMANDS: ReadonlyArray = [ - // pr-agent - { - kind: 'pragent', - name: 'review', - label: '/review', - descKey: 'chatPane.cmdReviewDesc', - insertAs: '/review', - }, - { - kind: 'pragent', - name: 'describe', - label: '/describe', - descKey: 'chatPane.cmdDescribeDesc', - insertAs: '/describe', - }, - // /improve:shim 强制 gfm_markdown=True 后,improve 走「汇总建议 → publish_comment → - // review.md」路径(非 committable,inline 模式仍不可用),parse-output 按 - // generate_summarized_suggestions 的
模板解析出带重要度评分的 finding。 - { - kind: 'pragent', - name: 'improve', - label: '/improve', - descKey: 'chatPane.cmdImproveDesc', - insertAs: '/improve', - }, - { - kind: 'pragent', - name: 'ask', - label: '/ask', - descKey: 'chatPane.cmdAskDesc', - insertAs: '/ask ', - }, - // review 决断 (跟 PR header 按钮共用 prs:setLocalStatus,写 Bitbucket reviewer status) - { - kind: 'review-action', - name: 'approve', - label: '/approve', - descKey: 'chatPane.cmdApproveDesc', - insertAs: '/approve', - reviewStatus: 'approved', - }, - { - kind: 'review-action', - name: 'needswork', - label: '/needswork', - descKey: 'chatPane.cmdNeedsworkDesc', - insertAs: '/needswork', - reviewStatus: 'needs_work', - }, -]; - -interface ChatInputBarProps { - pr: StoredPullRequest | null; - prAgent: PrAgentStatus; - /** LLM 是否已配置;未配置时禁用输入(即便 pr-agent 运行时就绪也无法调用) */ - llmConfigured: boolean; - /** - * 本 PR 上的活动 run 工具;非空时在 send 按钮旁额外渲染 stop 按钮。 - * 队列模型下输入永不因此禁用 (新提交进队列)。 - */ - runningTool: ReviewRunTool | null; - onRun: (tool: ReviewRunTool, question?: string) => void; - /** 无 '/' 前缀的自然语言输入 → 交给自由规划 Agent(对话即委派,见设计「会话 Agent 化」)。 */ - onAgentAsk: (question: string) => void; - /** - * 终止当前活动 run。仅 runningTool 非空时有意义;ChatPane 已绑好对应 runId。 - * stop 按钮跟 send 共用槽位:runningTool 时点击触发此回调而非 onRun - */ - onCancel?: () => void; - /** /approve /needswork 命令触发的 review 决断,跟 PR header 按钮共用 prs:setLocalStatus */ - onSetReviewStatus?: (status: LocalPrStatus) => void; - /** Agent 是否跑在当前 PR:决定图标按钮高亮 + 运行中文案 + 禁用重复发起(其它 PR 在跑不禁用本 PR)。 */ - agentRunningHere: boolean; - /** 触发一键自动评审微流程(describe→review→条件追问→总结)。 */ - onAgentReview: () => void; -} - -// 输入历史:最近 5 次成功提交,localStorage 持久化。Up/Down 按键在 textarea 末尾 -// 输入位置时回放。命中 / dismissed 后焦点保持在 textarea 上 -const CHAT_HISTORY_KEY = 'meebox.chatHistory'; -const CHAT_HISTORY_MAX = 5; - -/** - * pr-agent /review 输出的 issue body 尾部含 `[file: , lines: -]` - * marker — 是我们注入的 prompt directive 让 parser 抽 anchor 的,对用户无意义。 - * FindingCard 渲染前 / 转 draft 时统一清洗 - */ -function stripFindingMarker(body: string): string { - // 路径可能含 `[]`:带 lines 时用惰性 `.+?` + 必现 `, lines:` 后缀界定(`.` 匹配 `]`,不被 - // 路径里的 `]` 误截);无 lines 时回退到不含 `]` 的旧式。末尾锚定,只清尾部 marker。 - return body - .replace( - /\s*\[\s*file\s*:\s*(?:.+?\s*,\s*lines?\s*:\s*\d+(?:\s*[-–—]\s*\d+)?|[^\]\n]*?)\s*\]\s*$/i, - '', - ) - .trimEnd(); -} - -/** - * 把 pr-agent GFM 输出里的内联 HTML 标签归一成 markdown。finding 卡片走 ReactMarkdown - * (允许 HTML) 能正常渲染这些标签,但转成草稿正文落进编辑器 textarea / 发布到远端后, - * 裸 `` `
` 不一定被渲染,会暴露成字面标签。这里把常见内联标签转成等价 - * markdown:`x`→`` `x` ``、`
`→换行、`/`→`**`、`/`→`*`。 - * 空 `` 直接丢弃,避免产出孤立的空反引号。 - */ -function htmlInlineToMarkdown(text: string): string { - return text - .replace(/<\s*br\s*\/?\s*>/gi, '\n') - .replace(/<\s*code\s*>([\s\S]*?)<\s*\/\s*code\s*>/gi, (_, inner: string) => - inner.trim() ? `\`${inner}\`` : '', - ) - .replace(/<\s*(?:strong|b)\s*>([\s\S]*?)<\s*\/\s*(?:strong|b)\s*>/gi, '**$1**') - .replace(/<\s*(?:em|i)\s*>([\s\S]*?)<\s*\/\s*(?:em|i)\s*>/gi, '*$1*'); -} - -function loadChatHistory(): string[] { - try { - const raw = localStorage.getItem(CHAT_HISTORY_KEY); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - // 防御性筛掉非 string 项,并截到上限 (历史 schema 改过也不爆) - return parsed.filter((v): v is string => typeof v === 'string').slice(0, CHAT_HISTORY_MAX); - } catch { - return []; - } -} - -function pushChatHistory(value: string): string[] { - const trimmed = value.trim(); - if (!trimmed) return loadChatHistory(); - const prev = loadChatHistory(); - // 去重:跟最近一条一样不重复入栈 (用户连续打同样命令很常见)。也清掉历史里 - // 重复的旧条目,让最新的那条上移到顶 - const deduped = prev.filter((v) => v !== trimmed); - const next = [trimmed, ...deduped].slice(0, CHAT_HISTORY_MAX); - try { - localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(next)); - } catch { - /* quota / private mode → 内存里历史能继续工作就行 */ - } - return next; -} - -/** - * 输入栏:textarea + 命令按钮 + `/` 触发的自动补全。 - * - * 提交语义 (按 Enter 或点发送): - * - 空输入 → 不提交 - * - `/describe` / `/review` 开头 → 触发对应工具,忽略后面文字 - * - `/ask <文本>` 开头 → 触发 ask,rest 作 question - * - `/xxx` 但 xxx 未知 → 报错提示 - * - 不以 `/` 开头 → 等价于 `/ask <整段>` - * - * Shift+Enter 换行,Enter 提交。textarea 高度 1→5 行自适应,超过 5 行内部滚动。 - */ -function ChatInputBar({ - pr, - prAgent, - llmConfigured, - runningTool, - onRun, - onAgentAsk, - onCancel, - onSetReviewStatus, - agentRunningHere, - onAgentReview, -}: ChatInputBarProps) { - const { t } = useTranslation(); - const [input, setInput] = useState(''); - const [parseError, setParseError] = useState(null); - // PR 切换时清掉异常提示 + 输入框残留 (避免跨 PR 显示陈旧的错误"未知命令" 等) - useEffect(() => { - setParseError(null); - setInput(''); - }, [pr?.localId]); - const [cmdMenuOpen, setCmdMenuOpen] = useState(false); - // 自动补全菜单选中项索引 (textarea 输入 / 时显示的浮层) - const [autocompleteIdx, setAutocompleteIdx] = useState(0); - // 已经为某个特定输入值关闭过菜单 (Esc / 选中后插入)。input 一变就失效 - // → 用户继续打字时菜单会自然重新出现,但选中 / Esc 后不会立刻重弹 - const [dismissedFor, setDismissedFor] = useState(null); - // 历史回放:从最新到最老的栈;historyIdx 表示当前正在浏览的位置 (-1 = 不在浏览态) - const [history, setHistory] = useState(() => loadChatHistory()); - const [historyIdx, setHistoryIdx] = useState(-1); - // 进入历史浏览前用户正在编辑的内容;按 Down 回到底端时还原回去,模仿 shell 行为 - const draftBeforeHistoryRef = useRef(''); - const textareaRef = useRef(null); - const cmdMenuRef = useRef(null); - - // 队列模型:仅 !pr / pr-agent 未就绪 时禁用 input。activeRun / busyOnOtherPr - // 不再阻塞新提交 (会排队 by main)。running 决定是否渲染 stop 按钮:除活动工具 run 外, - // Agent 自身执行阶段(思考 / 编排,无工具 run 占用)也算「运行中」,以便随时取消。 - const running = runningTool !== null || agentRunningHere; - // LLM 未配置时一并禁用:即便 pr-agent 运行时就绪,没有模型也无法发起调用 - const disabled = !pr || !prAgent.available || !llmConfigured; - // stop 按钮点过后等 main 回 queueChanged 才会改变状态;中间这段时间二次点击 - // 应失效,避免反复 spam abort - const [stopRequested, setStopRequested] = useState(false); - // running → false 时 (run 结束了) 重置 stopRequested,下次起 run 又能取消 - useEffect(() => { - if (!running) setStopRequested(false); - }, [running]); - const trimmed = input.trim(); - // `/` 开头 + 命令名还没敲完整 (没空格) → 显示候选;已为当前 input dismiss 过则隐藏 - const showAutocomplete = - !disabled && dismissedFor !== input && input.startsWith('/') && !input.includes(' '); - const filtered = showAutocomplete - ? COMMANDS.filter((c) => c.label.startsWith(input.split(' ')[0] ?? '')) - : []; - - // 输入变化时重置选中项到首条 (候选集变了) - useEffect(() => { - setAutocompleteIdx(0); - }, [input]); - - // `/` 命令按钮触发的弹出菜单:点击外部 / Esc / 选中命令时关闭 - useEffect(() => { - if (!cmdMenuOpen) return; - const onDown = (e: MouseEvent): void => { - if (!cmdMenuRef.current?.contains(e.target as Node)) { - setCmdMenuOpen(false); - } - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') setCmdMenuOpen(false); - }; - document.addEventListener('mousedown', onDown); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDown); - document.removeEventListener('keydown', onKey); - }; - }, [cmdMenuOpen]); - - // textarea 高度:用户拖顶边 handle 调整。 - // - // 不用 CSS `resize: vertical` 因为它的 handle 在右下角、向下拖才放大 —— - // 但 input 整体被钉在 chat 面板底部,视觉上 textarea 是"向上扩展",跟操作方向 - // 反直觉。改成顶边自绘 handle (类似 chat-pane-resize-handle 模式),向上拖 = 放大, - // 视觉操作直觉一致。 - // - // 边界跟 css 里 min-height (2 行) / max-height (5 行) 一致;state null 时不写 - // inline style,由 css 默认值起手 - const [textareaHeightPx, setTextareaHeightPx] = useState(null); - const handleTextareaResizeStart = (e: React.MouseEvent): void => { - e.preventDefault(); - const el = textareaRef.current; - if (!el) return; - const startY = e.clientY; - const startHeight = el.getBoundingClientRect().height; - // 跟 css token: $fs-md=13 * $lh-normal=1.4 = 18.2 px/line;$space-3=6 px padding 上下 = 12 px - const MIN = Math.round(13 * 1.4 * 2 + 12); - const MAX = Math.round(13 * 1.4 * 5 + 12); - const onMove = (ev: MouseEvent): void => { - // 上拖 dy < 0 → 高度增加;下拖反之 - const dy = ev.clientY - startY; - const next = Math.min(MAX, Math.max(MIN, startHeight - dy)); - setTextareaHeightPx(next); - }; - const onUp = (): void => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - }; - - const handleInsertCommand = (cmd: CommandSpec): void => { - setInput(cmd.insertAs); - setParseError(null); - setCmdMenuOpen(false); - // 选中后立即关掉补全菜单 (insertAs 可能 "/describe" 没空格,否则会一直撑着)。 - // dismissedFor 绑当前 input 值,用户继续打字 input 变了菜单会重新打开 - setDismissedFor(cmd.insertAs); - const el = textareaRef.current; - if (el) { - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(cmd.insertAs.length, cmd.insertAs.length); - }); - } - }; - - const submit = (): void => { - if (disabled || !trimmed) return; - setParseError(null); - // 解析命令头:'/' 起手 → COMMANDS 表里找;无 '/' → 等价 /ask <整段> - let cmd: CommandSpec; - let rest = ''; - if (trimmed.startsWith('/')) { - const spaceIdx = trimmed.indexOf(' '); - const head = spaceIdx < 0 ? trimmed : trimmed.slice(0, spaceIdx); - rest = spaceIdx < 0 ? '' : trimmed.slice(spaceIdx + 1).trim(); - const found = COMMANDS.find((c) => c.label === head); - if (!found) { - setParseError( - t('chatPane.unknownCommand', { - head, - cmds: COMMANDS.map((c) => c.label).join(' / '), - }), - ); - return; - } - cmd = found; - } else { - // 无 '/' → 自然语言「对话即委派」:交给自由规划 Agent(而非 /ask)。 - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onAgentAsk(trimmed); - return; - } - // review-action:/approve /needswork 没有参数,多余文本拒绝以免误用 - if (cmd.kind === 'review-action') { - if (rest) { - setParseError(t('chatPane.commandNoArgs', { cmd: cmd.label })); - return; - } - if (!onSetReviewStatus) return; // 没装回调直接忽略 (保护性) - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onSetReviewStatus(cmd.reviewStatus); - return; - } - // pragent:/ask 必须带问题,其他工具空 question - let question: string | undefined; - if (cmd.name === 'ask') { - if (!rest) { - setParseError(t('chatPane.askNeedsQuestion')); - return; - } - question = rest; - } - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onRun(cmd.name, question); - }; - - // 历史回放工具:根据 idx 设 textarea 内容;idx = -1 表示退出浏览态,恢复 draft - const applyHistoryIdx = (nextIdx: number): void => { - setHistoryIdx(nextIdx); - setInput(nextIdx < 0 ? draftBeforeHistoryRef.current : (history[nextIdx] ?? '')); - // 光标移到末尾,下一次 Up/Down 行为可预期 - const el = textareaRef.current; - if (el) { - requestAnimationFrame(() => { - el.focus(); - const len = el.value.length; - el.setSelectionRange(len, len); - }); - } - }; - - // 判断是否应让 Up/Down 触发历史回放:textarea 光标必须在首行 / 末行边缘, - // 否则让 Up/Down 走原生光标移动 (多行编辑时还在行内导航不能被劫持) - const atFirstLine = (): boolean => { - const el = textareaRef.current; - if (!el) return false; - return el.value.slice(0, el.selectionStart).indexOf('\n') < 0; - }; - const atLastLine = (): boolean => { - const el = textareaRef.current; - if (!el) return false; - return el.value.slice(el.selectionEnd).indexOf('\n') < 0; - }; - - const onKeyDown = (e: React.KeyboardEvent): void => { - // 输入法 composing 中:所有快捷键都不拦截,交给 IME 处理 - if (e.nativeEvent.isComposing) return; - - // 自动补全菜单打开时:拦截 Up/Down/Enter/Tab/Esc 用于菜单导航,避免落到 textarea - if (showAutocomplete && filtered.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setAutocompleteIdx((i) => (i + 1) % filtered.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setAutocompleteIdx((i) => (i - 1 + filtered.length) % filtered.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const cmd = filtered[Math.min(autocompleteIdx, filtered.length - 1)]; - if (cmd) handleInsertCommand(cmd); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setDismissedFor(input); - return; - } - } - - // 历史回放:菜单未打开时,Up/Down 在边缘行 → 翻历史。中间行让原生光标移动接管 - if (e.key === 'ArrowUp' && history.length > 0 && atFirstLine()) { - e.preventDefault(); - if (historyIdx < 0) { - // 首次进浏览态:把当前编辑内容存为 draft,方便 Down 回到底端时复原 - draftBeforeHistoryRef.current = input; - } - applyHistoryIdx(Math.min(historyIdx + 1, history.length - 1)); - return; - } - if (e.key === 'ArrowDown' && historyIdx >= 0 && atLastLine()) { - e.preventDefault(); - applyHistoryIdx(historyIdx - 1); - return; - } - - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - submit(); - } - }; - - const placeholder = !prAgent.available - ? t('chatPane.placeholderNotReady') - : !llmConfigured - ? t('chatPane.placeholderNeedLlm') - : !pr - ? t('chatPane.placeholderNoPr') - : t('chatPane.placeholderReady'); - - return ( -
{ - e.preventDefault(); - submit(); - }} - > - {showAutocomplete && filtered.length > 0 && ( -
    - {filtered.map((c, i) => { - const active = i === Math.min(autocompleteIdx, filtered.length - 1); - const prev = filtered[i - 1]; - const needDivider = prev !== undefined && prev.kind !== c.kind; - return ( -
  • - -
  • - ); - })} -
- )} -
- {/* 顶边拖动 handle:向上拖 → textarea 高度增加,跟视觉扩展方向一致 */} -
-

。 - // 关键是「保持简洁」——只在现象/影响/建议的语义边界换行,不得借分段扩写内容。 - // 须与上面的 anchor marker 指令协同:分段在正文内部,marker 仍独占最末行。 - const reviewLayoutDirective = - req.tool === 'review' - ? [ - 'FORMATTING ONLY: Keep each `key_issues_to_review` item as concise as you', - 'already would — do NOT add length, padding, or extra explanation. The only', - 'change is line breaks: instead of one dense run-on paragraph, insert a BLANK', - 'LINE at the natural boundaries (e.g. problem → impact → suggested fix) so the', - 'text reads as a few short paragraphs. Same words, better layout.', - '', - 'This applies to the issue PROSE only. The machine-readable anchor marker', - 'described above still goes on its OWN LAST LINE, after the final paragraph', - '(a blank line may precede it).', - ].join('\n') - : ''; - - const extraParts = [ - langDirective, - reviewAnchorDirective, - reviewLayoutDirective, - prContext, - matchedRuleInstructions, - ].filter((s) => s.trim()); - if (extraParts.length > 0) { - const envKey = - req.tool === 'describe' - ? 'PR_DESCRIPTION__EXTRA_INSTRUCTIONS' - : req.tool === 'review' - ? 'PR_REVIEWER__EXTRA_INSTRUCTIONS' - : req.tool === 'improve' - ? 'PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS' - : 'PR_QUESTIONS__EXTRA_INSTRUCTIONS'; - env[envKey] = extraParts.join('\n\n---\n\n'); - } - if (matchedRuleId) { - logger.info( - { runId: run.id, ruleId: matchedRuleId, tool: req.tool }, - 'pragent run: matched rule', - ); - } - if (prContext) { - logger.debug( - { runId: run.id, tool: req.tool, contextChars: prContext.length }, - 'pragent run: pr context injected', - ); - } - - // ask 工具:问题作为位置参数(user turn,spawn args 单元素,含空格也是一个 arg 不切分), - // 并在问题**末尾**硬性追加语言要求。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对 - // 自由问答常被大量英文 diff(full diff 数万 token)盖过 → 模型用英文作答;在 user turn 末尾 - // (近因位置、用目标语言书写)再要求一次,显著提升按 UI 语言作答的遵循度。en-US 返回空、不追加。 - const askLangSuffix = req.tool === 'ask' ? askLanguageSuffixFor(getMainLanguage()) : ''; - const askQuestion = - req.tool === 'ask' && req.question - ? askLangSuffix - ? `${req.question}\n\n${askLangSuffix}` - : req.question - : undefined; - const extraArgs = askQuestion ? [askQuestion] : undefined; - - // embedded 策略:执行期在嵌入式安装目录补空 .secrets.toml 压掉启动告警 - // (直接写安装目录;memo 化只首次做)。local-cli 不需要 (pipx 装的 pr-agent - // 路径不同,告警也不出) - if (prAgentBridge.strategy === 'embedded' && embeddedPythonPath) { - await ensureEmbeddedSecrets(embeddedPythonPath); - } - - const result = await prAgentBridge.run({ - prUrl: pr.url, - tool: req.tool, - env, - onLine, - cwd: wt.path, - targetBranch: wt.targetBranchName, - extraArgs, - signal: ac.signal, - }); - // 真实 token 用量(onLine 累加的 stderr 哨兵行),落到 succeeded / llm-failed 收尾。 - const tokenUsage = finalizeUsage(usageAcc); - // pr-agent 的 local provider 把生成结果**写到工作树根的 markdown 文件**: - // /describe → /description.md (走 publish_description) - // /review → /review.md (走 publish_comment) - // /ask → /review.md ← 共用同一文件 (publish_comment 会覆盖) - // /improve → /improve.md ← 汇总建议走 publish_comment,经 LOCAL__REVIEW_PATH - // 重定向与 review.md 分流(见上方 env 注入) - // 走 worktree 路径,cleanup 前必须先把文件读出来。 - const outFile = - req.tool === 'describe' - ? 'description.md' - : req.tool === 'improve' - ? 'improve.md' - : 'review.md'; - let fileContent = ''; - try { - fileContent = await fs.readFile(path.join(wt.path, outFile), 'utf8'); - } catch (readErr) { - logger.warn( - { err: readErr, wtPath: wt.path, outFile, runId: run.id }, - 'pr-agent local provider output file missing; fall back to stdout', - ); - } - // /ask 输出里 pr-agent 把问题原样回显在 answer body 顶部 (跟 chat 输入气泡完全 - // 重复)。在解析前把跟用户问题逐字匹配的整行删掉,避免渲染时出现两次问题 - const cleanedContent = - req.tool === 'ask' && req.question?.trim() - ? stripAskQuestionEcho(fileContent, req.question, askLangSuffix) - : fileContent; - const parsed = parseReviewOutput(cleanedContent || result.stdout, req.tool); - // M4 草稿再摄入:/review 成功完成时丢掉 pending+finding 旧草稿, - // 让本轮 ChatPane 上的 finding 列表成为新的候选源。edited/posted/rejected/ - // manual 保留不动。失败的 /review 不触发清理 (没建设性数据)。 - if (req.tool === 'review') { - try { - const dropped = await dropPendingFindingDrafts(stateStore, pr.localId); - if (dropped > 0) { - logger.info( - { runId: run.id, localId: pr.localId, dropped }, - 'pragent /review: dropped stale pending drafts', - ); - broadcastDraftsChanged(pr.localId); - } - } catch (err) { - logger.warn({ err, runId: run.id }, 'dropPendingFindingDrafts failed'); - } - } - // pr-agent CLI 可能 exit 0 但 stdout 里其实是 LLM 调用全失败 (litellm - // AuthenticationError / "Failed to generate prediction with any model" 等 - // marker)。parseReviewOutput 会在 ParsedReviewOutput.llmFailure 标出 — - // 此时不算 succeeded,落盘为 failed + reason='llm-error',UI 用红色失败 - // chip 渲染而不是"完成" - if (parsed.llmFailure) { - logger.warn( - { runId: run.id, reason: parsed.llmFailure.message }, - 'pragent exit 0 but LLM call failed; marking run as failed', - ); - return await finishWith({ - status: 'failed', - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - exitCode: result.exitCode, - errorReason: 'llm-error', - errorMessage: parsed.llmFailure.message, - stdout: fileContent - ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` - : result.stdout, - stderr: stripUsageSentinels(result.stderr), - findings: parsed.findings, - summary: parsed.summary, - tokenUsage, - }); - } - return await finishWith({ - status: 'succeeded', - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - exitCode: result.exitCode, - // 持久化「LLM 真实产出」(文件内容);stdout 留作日志在折叠区供排障 - stdout: fileContent - ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` - : result.stdout, - stderr: stripUsageSentinels(result.stderr), - findings: parsed.findings, - summary: parsed.summary, - tokenUsage, - }); - } catch (err) { - if (err instanceof PrAgentRunError) { - // 用户主动取消 → status='cancelled',其它 reason → 'failed'。 - // 二者都仍走 finishReviewRun 落盘,让 UI 能从历史 run 里看到这次取消事件 - const status: ReviewRunStatus = err.reason === 'cancelled' ? 'cancelled' : 'failed'; - logger.warn( - { runId: run.id, reason: err.reason, exitCode: err.result.exitCode }, - `pragent run ${status}`, - ); - // 失败 / 取消时也尽量解析已收集的 stdout:很多情况 pr-agent 已写了一部分输出 - const partialStdout = err.result.stdout ?? ''; - const parsed = partialStdout - ? parseReviewOutput(partialStdout, req.tool) - : { findings: [], summary: undefined }; - // 失败 / 取消前可能已有若干次 LLM 调用,尽量把已产生的 token 用量也记上 - const tokenUsage = finalizeUsage(usageAcc); - return await finishWith({ - status, - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - exitCode: err.result.exitCode, - errorReason: err.reason, - errorMessage: err.message, - stdout: err.result.stdout, - stderr: stripUsageSentinels(err.result.stderr), - findings: parsed.findings, - summary: parsed.summary, - tokenUsage, - }); - } - // 非预期异常:仍记一笔 failed,避免 run 永远卡在 running,再把异常往上抛 - await finishWith({ - status: 'failed', - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - errorMessage: err instanceof Error ? err.message : String(err), - }); - throw err; - } finally { - await wt.cleanup(); - } - }; - - /** - * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 - * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 - */ - const pump = (): void => { - while (active.size < maxConcurrency && waiting.length > 0) { - const item = waiting.shift()!; - active.set(item.info.runId, item); - item.ac = new AbortController(); - void executeRun(item) - .then((finished) => item.resolve(finished)) - .catch((err: unknown) => { - item.reject(err instanceof Error ? err : new Error(String(err))); - }) - .finally(() => { - active.delete(item.info.runId); - broadcastQueueChanged(); - // 放微任务里再泵,避免递归栈累积 - queueMicrotask(pump); - }); - } - broadcastQueueChanged(); - }; - - /** - * 入队一个 pr-agent run(与用户手动 run 共用同一队列 / 并发 / 取消机制)。dedup:同 PR - * 同工具已在执行 / 排队则抛错(/ask 不限)。resolve 完成的 ReviewRun。 - * `pragent:run` handler 与 Agent 编排器(runTool)都走它。 - */ - const enqueuePragentRun = ( - pr: StoredPullRequest, - tool: ReviewRunTool, - question?: string, - priority: 'user' | 'agent' = 'user', - ): Promise => { - if (tool !== 'ask') { - const sameTask = (q: QueueItem): boolean => - q.info.prLocalId === pr.localId && q.info.tool === tool; - if ([...active.values()].some(sameTask) || waiting.some(sameTask)) { - throw new Error(t('prAgent.duplicateTask', { tool })); - } - } - // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 - const runId = makeRunId(new Date()); - return new Promise((resolve, reject) => { - const item: QueueItem = { - info: { - runId, - prLocalId: pr.localId, - repoSlug: pr.repo.repoSlug, - prNumber: pr.remoteId, - tool, - question: tool === 'ask' ? question : undefined, - enqueuedAt: new Date().toISOString(), - startedAt: null, - }, - req: { localId: pr.localId, tool, question }, - pr, - priority, - resolve, - reject, - }; - // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 - if (priority === 'user') { - const firstAgentIdx = waiting.findIndex((q) => q.priority === 'agent'); - if (firstAgentIdx >= 0) waiting.splice(firstAgentIdx, 0, item); - else waiting.push(item); - } else { - waiting.push(item); - } - logger.info( - { runId, localId: pr.localId, tool, priority, queueLen: waiting.length }, - 'pragent run enqueued', - ); - pump(); - }); - }; - - ipcMain.handle( - 'pragent:run', - async ( - _evt, - req: IpcChannels['pragent:run']['request'], - ): Promise => { - if (!getPrAgentBridge()) { - throw new Error(t('prAgent.notReadyDetail')); - } - // 早期校验:/ask 必须带 question,避免排队后才报错 - if (req.tool === 'ask' && !req.question?.trim()) { - throw new Error(t('prAgent.askNeedsQuestion')); - } - const pr = await findPrOrThrow(req.localId); - return enqueuePragentRun(pr, req.tool, req.question); - }, - ); - - // ── Agent 评审编排:共享 chat 通道 + 单 PR 微流程,agent:run 与 AutoPilot 都用 ── - type AgentChat = (input: { - system: string; - user: string; - }) => Promise<{ text: string; usage?: TokenUsage }>; - - /** 设置 LLM env + 临时 chat cwd + chat 函数,运行 fn,收尾清理临时目录。 - * signal:用户停止时 abort → 杀掉在跑的 LLM chat 子进程,让思考阶段也能立即中止(不必等模型返回)。 */ - const withAgentChat = async ( - fn: (chat: AgentChat) => Promise, - signal?: AbortSignal, - ): Promise => { - const bridge = getPrAgentBridge(); - if (!bridge) throw new Error(t('prAgent.notReadyDetail')); - // 复用与 pr-agent run 同一套 LLM env(provider 凭据 / 模型 / 代理 / 响应语言)。 - const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); - const env: Record = { - ...buildProxyEnv(bootstrap.config.proxy), - ...(activeLlm ? buildPragentEnv(activeLlm) : {}), - CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), - // Agent 编排通道(规划 / 判读 / 收尾 / 对话)是路由 + 轻量综合,非深度代码分析(那在 - // pr-agent /review 里)。本机 CLI 模式下调低推理档(codex: model_reasoning_effort=minimal) - // 提速;仅作用于本 chat spawn,pr-agent 工具 run 的 env 不含此项 → /review 仍满档推理。 - // 非 CLI 模式(API)由 CLI handler 之外的路径处理,该 env 无副作用。 - MEEBOX_CLI_REASONING: 'low', - }; - // chat 子进程落到中性临时目录(cli 模式避免吃到被评审仓库的 CLAUDE.md)。 - const chatCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'meebox-agent-chat-')); - try { - const chat: AgentChat = async ({ system, user }) => { - const r = await bridge.chat({ system, user, env, cwd: chatCwd, signal }); - const acc: UsageAcc = { prompt: 0, completion: 0, total: 0, calls: 0, any: false }; - for (const line of (r.stderr ?? '').split('\n')) accumulateUsageSentinel(line, acc); - return { text: r.stdout.trim(), usage: finalizeUsage(acc) }; - }; - return await fn(chat); - } finally { - await fs.rm(chatCwd, { recursive: true, force: true }); - } - }; - - /** - * 每个编排步骤的统一出口:① 后台日志(工具选择 / 判读 / 收尾各落一条,便于排障与离线回看); - * ② 广播给渲染层(agent:stepProgress)做过程化展示。thought / result 截断避免刷屏。 + ipcMain.handle('comments:reply', pr.replyComment); // 回复评论 + ipcMain.handle('comments:create', pr.createComment); // 新建 summary 评论 + ipcMain.handle('comments:delete', pr.deleteComment); // 删除自己的评论 + ipcMain.handle('comments:edit', pr.editComment); // 编辑自己的评论 + ipcMain.handle('comments:fetchAttachment', pr.fetchAttachment); // 拉评论内嵌图片(代理带 PAT) + ipcMain.handle('prs:list', pr.listPrs); // PR 列表(仅活动连接) + ipcMain.handle('prs:refresh', pr.refreshPrs); // 立即轮询刷新 + ipcMain.handle('prs:lastSync', pr.getLastSync); // 最近一次同步时间 + ipcMain.handle('prs:setLocalStatus', pr.setPrStatus); // 设置审阅状态(先远端后本地) + ipcMain.handle('prs:merge', pr.mergePr); // 合并 PR + ipcMain.handle('repo:sync', pr.syncRepo); // 同步 PR 所属 repo 本地镜像 + ipcMain.handle('diff:listChangedFiles', pr.listChangedFiles); // 变更文件列表 + ipcMain.handle('diff:listConflictFiles', pr.listConflictFiles); // 合并冲突文件列表(文件树警示) + ipcMain.handle('diff:getFileContent', pr.getFileContent); // 文件内容(base / head 一侧) + ipcMain.handle('diff:commentCountCached', pr.getCommentCountCached); // 评论数角标(仅缓存) + ipcMain.handle('diff:listComments', pr.listComments); // 拉评论(缓存 + in-flight 去重) + ipcMain.handle('diff:listCommits', pr.listCommits); // 提交列表 + ipcMain.handle('diff:listActivity', pr.listActivity); // 评审决断活动事件(时间线) + ipcMain.handle('diff:commitCount', pr.getCommitCount); // 提交数角标(本地 git) + ipcMain.handle('diff:getBlame', pr.getBlame); // blame + PR 引入行 + ipcMain.handle('repo:getTotalSize', pr.getTotalSize); // 本地镜像总占用(设置页) + ipcMain.handle('drafts:list', pr.getDrafts); // 草稿列表 + ipcMain.handle('drafts:create', pr.addDraft); // 新建草稿 + ipcMain.handle('drafts:update', pr.patchDraft); // 更新草稿 + ipcMain.handle('drafts:delete', pr.removeDraft); // 删除草稿 + ipcMain.handle('drafts:publishBatch', pr.publishDraftBatch); // 批量发布草稿到远端 + ipcMain.handle('findingClosures:list', pr.getFindingClosures); // finding 关闭关系列表 + ipcMain.handle('findingClosures:create', pr.addClosure); // 复评取代/撤销 → 关闭原 finding + ipcMain.handle('findingClosures:delete', pr.removeClosure); // 撤销关闭 + + /* + * 配置操作 + * 读写 config.yaml(热生效 / 草稿暂存)及连接 / 代理试连 */ - // 后台日志只留骨架(kind / tool / 用时):thought 与 result(含用户输入 / 总结正文)不入日志, - // 避免刷屏 + 泄漏内容;完整步骤已落 transcript.json,需要时从那里回看。 - const emitAgentStep = (pr: StoredPullRequest, sessionId: string, step: AgentStep): void => { - logger.info( - { - prLocalId: pr.localId, - sessionId, - kind: step.kind, - tool: step.toolCall?.tool, - thinkMs: step.thinkMs, - }, - 'agent step', - ); - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); - } - }; - - // 编排 Agent(手动评审 agent:run + 自由规划 agent:ask)每 PR 至多一个在跑,AbortController 供 - // agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 - const agentControllers = new Map(); - - // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。区别于 - // agentControllers(仅手动可停会话):这里**手动 run/ask 与 AutoPilot 后台评审一并计入**, - // 让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 - const runningAgentPrs = new Set(); - const broadcastAgentRunning = (): void => { - const prLocalIds = [...runningAgentPrs]; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:runningChanged', { prLocalIds }); - } - }; - const markAgentRunning = (localId: string): void => { - runningAgentPrs.add(localId); - broadcastAgentRunning(); - }; - const unmarkAgentRunning = (localId: string): void => { - if (runningAgentPrs.delete(localId)) broadcastAgentRunning(); - }; - - /** 取消某 PR 的全部 pr-agent run:active 的 SIGKILL,waiting 的出队 + reject。 */ - const cancelRunsForPr = (localId: string): void => { - for (const item of active.values()) if (item.req.localId === localId) item.ac?.abort(); - let removed = false; - for (let i = waiting.length - 1; i >= 0; i--) { - if (waiting[i]!.req.localId === localId) { - const [q] = waiting.splice(i, 1); - q!.reject(new Error('pr removed')); - removed = true; - } - } - if (removed) broadcastQueueChanged(); - }; - - /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ - const terminateAgentForPr = (localId: string): void => { - agentControllers.get(localId)?.abort(); - cancelRunsForPr(localId); - }; - - /** - * poll tick 后调用:把已被移除 / purge(不再在 listStoredPullRequests 里)的 PR 上仍在执行的 - * agent 操作一律直接终止——PR 都没了,继续评审无意义且浪费 LLM / 占用 worktree。 + ipcMain.handle('config:read', config.readConfig); // 读当前内存配置 + ipcMain.handle('config:setReposDir', config.setReposDir); // 设仓库目录(重启生效) + ipcMain.handle('config:setLanguage', config.setLanguage); // 设 UI 语言(热生效) + ipcMain.handle('config:setLlm', config.setLlm); // 设 LLM Provider 配置 + ipcMain.handle('config:setAgent', config.setAgent); // 设 Agent 配置(含 agent.dir) + ipcMain.handle('agent:setAutopilotEnabled', config.setAutopilotEnabled); // AutoPilot 开关 + ipcMain.handle('config:setConnections', config.setConnections); // 设连接(热重建 adapter/poller) + ipcMain.handle('config:setProxy', config.setProxy); // 设代理(热重建 adapter) + ipcMain.handle('config:testProxy', config.testProxy); // 试连代理(不写配置) + ipcMain.handle('config:testConnection', config.testConnection); // 试连连接(不写配置) + ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) + ipcMain.handle('config:setPoller', config.setPoller); // 设轮询间隔(热替换定时器) + + /* + * Agent 交互 + * 规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列 */ - const terminateAgentsForGonePrs = async (): Promise => { - const opPrIds = new Set(); - for (const id of agentControllers.keys()) opPrIds.add(id); - for (const item of active.values()) opPrIds.add(item.req.localId); - for (const item of waiting) opPrIds.add(item.req.localId); - if (opPrIds.size === 0) return; - const live = new Set((await listStoredPullRequests(stateStore)).map((p) => p.localId)); - for (const id of opPrIds) { - if (!live.has(id)) { - logger.info({ prLocalId: id }, 'agent ops terminated: pr removed/purged'); - terminateAgentForPr(id); - } - } - }; - - /** 对一个 PR 跑评审微流程(共用 enqueue 队列 / 持久化 / 步骤广播)。 */ - const runReviewForPr = ( - pr: StoredPullRequest, - agentContext: AgentContext, - chat: AgentChat, - signal?: AbortSignal, - autopilot = false, - ): Promise => { - const agentCfg = bootstrap.config.agent; - const matchedRule = pickMatchingRule(agentContext.rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: 'review', - }); - return runAgentReview(pr, { - stateStore, - // 编排派发的 run 走 agent 低优先级泳道:用户随时点 /review 会插到它们之前。 - enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), - chat, - agentContext, - matchedRule, - language: getMainLanguage(), - // 工具目录注入:修改类工具按 grants 门控(默认全禁,红线见 buildToolCatalog)。 - toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), - maxFollowupAsks: agentCfg.autopilot.max_followup_asks, - summaryMaxChars: agentCfg.summary_max_chars, - onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), - signal, - autopilot, - }); - }; - - /** - * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— - * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 - * updatedAt)。台账既给 PR 列表的建议徽标(★,手动 / 自动一视同仁),也供 AutoPilot 同版本去重。 - * 失败 / 用户停止(paused)不落,便于后续重试。 - */ - const recordReviewSummaryMessage = async ( - pr: StoredPullRequest, - session: AgentSession, - ): Promise => { - if (session.status !== 'done' || !session.summary) return; - await appendAgentMessage(stateStore, pr.localId, { - role: 'assistant', - content: session.summary, - recommendation: session.recommendation, - }); - await writeAutopilotLedger(stateStore, { - prLocalId: pr.localId, - autoReviewedUpdatedAt: pr.updatedAt, - decision: 'review', - recommendation: session.recommendation?.verdict, - at: new Date().toISOString(), - }); - // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:conversationChanged', { prLocalId: pr.localId }); - } - }; - - ipcMain.handle( - 'agent:run', - async ( - _evt, - req: IpcChannels['agent:run']['request'], - ): Promise => { - if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); - const pr = await findPrOrThrow(req.localId); - // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存。 - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 - const ac = new AbortController(); - agentControllers.set(pr.localId, ac); - markAgentRunning(pr.localId); - logger.info({ prLocalId: pr.localId }, 'agent review start (manual)'); - try { - const session = await withAgentChat( - (chat) => runReviewForPr(pr, agentContext, chat, ac.signal), - ac.signal, - ); - logger.info( - { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, - 'agent review done', - ); - // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 - await recordReviewSummaryMessage(pr, session); - return session; - } finally { - agentControllers.delete(pr.localId); - unmarkAgentRunning(pr.localId); - } - }, - ); - - const runPlanningForPr = ( - pr: StoredPullRequest, - userRequest: string, - agentContext: AgentContext, - chat: AgentChat, - signal: AbortSignal, - ): Promise => { - const agentCfg = bootstrap.config.agent; - const matchedRule = pickMatchingRule(agentContext.rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: 'review', - }); - return runAgentPlanning(pr, userRequest, { - stateStore, - enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), - chat, - agentContext, - toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), - matchedRule, - language: getMainLanguage(), - maxSteps: agentCfg.max_steps, - signal, - onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), - // 持久化 Agent 主动记下的非隐私条目到当前 Agent 目录的各可写文件(USER/MEMORY/AGENTS); - // SOUL.md 永不写。下一轮 loadAgentContext 现读即生效(跨会话记忆)。 - recordMemory: async (notes) => { - const dir = effectiveAgentDir(); - for (const kind of ['user', 'memory', 'agents'] as const) { - const added = await appendAgentNotes(dir, kind, notes[kind]).catch((err: unknown) => { - logger.warn({ err, kind }, 'record agent memory failed'); - return [] as string[]; - }); - if (added.length) logger.info({ kind, added }, 'agent memory recorded'); - } - }, - }); - }; - - ipcMain.handle( - 'agent:ask', - async ( - _evt, - req: IpcChannels['agent:ask']['request'], - ): Promise => { - if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); - const pr = await findPrOrThrow(req.localId); - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - const ac = new AbortController(); - agentControllers.set(pr.localId, ac); - markAgentRunning(pr.localId); - // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 - logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); - try { - const session = await withAgentChat( - (chat) => runPlanningForPr(pr, req.question, agentContext, chat, ac.signal), - ac.signal, - ); - logger.info( - { - prLocalId: pr.localId, - status: session.status, - steps: session.stepCount, - terminationReason: session.terminationReason, - }, - 'agent chat done', - ); - return session; - } finally { - agentControllers.delete(pr.localId); - unmarkAgentRunning(pr.localId); - } - }, - ); - - ipcMain.handle( - 'agent:stop', - (_evt, req: IpcChannels['agent:stop']['request']): IpcChannels['agent:stop']['response'] => { - const ac = agentControllers.get(req.localId); - if (!ac) return { ok: false }; - ac.abort(); - return { ok: true }; - }, - ); - - ipcMain.handle( - 'agent:getSession', - async ( - _evt, - req: IpcChannels['agent:getSession']['request'], - ): Promise => - getAgentSession(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:getConversation', - async ( - _evt, - req: IpcChannels['agent:getConversation']['request'], - ): Promise => - getAgentConversation(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:getTranscript', - async ( - _evt, - req: IpcChannels['agent:getTranscript']['request'], - ): Promise => - getAgentTranscript(stateStore, req.localId), - ); - - // === AutoPilot 调度(见 docs/arch/06-agent.md「AutoPilot」)=== - // Agent 编排层全局单并发:一次只跑一遍 pass(busy 锁);其派发的工具 run 在共享队列并行。 - // 触发节奏对齐轮询:每个 poller onTick(间隔 = poller.interval_seconds)评估一遍,不再另设独立的最小 - // 间隔守卫——准入门控 + 台账去重已防止重复评审 / 打爆 LLM;busy 锁防止上一遍未完又叠跑。 - let autopilotBusy = false; - const runAutopilotIfDue = (): void => { - const ap = bootstrap.config.agent.autopilot; - if (!ap.enabled || autopilotBusy || !getPrAgentBridge()) { - return; - } - autopilotBusy = true; - void (async () => { - try { - // 候选准入(硬性门控,自上而下): - // 1. 仅「待我评审」分类(discoveryFilters 含 review-requested)下、「待处理」状态(localStatus - // === 'pending')的 PR —— 已通过 / 标记需修改、或非待我评审的一律不自动评审。 - // (不支持发现分类的平台 discoveryFilters 为空 → 不命中,自然不自动触发。) - // 2. 会话中已有 /describe 或 /review 产出(成功 / 正在跑,手动或自动)→ 判定已评审过 / 评审中, - // 不再自动触发(评审失败无产出 → 不算,下轮可重试)。 - // 3. 仅排除「本版本已被判定跳过」的 PR(台账 decision='skipped')——避免对判过 skip 的 PR 反复 - // 重判;无产出又未被 skip 的待评审 PR 一律放行(不再因台账有任意记录就拦下)。 - // 再按 batch_size 截断。 - const prs = await listStoredPullRequests(stateStore); - const candidates: StoredPullRequest[] = []; - // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 - let reviewReqPending = 0; // 命中「待我评审 + 待处理」 - let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 - let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 - for (const pr of prs) { - if (candidates.length >= ap.batch_size) break; - if (!pr.discoveryFilters.includes('review-requested')) continue; - if (pr.localStatus !== 'pending') continue; - reviewReqPending++; - if (await hasReviewOutput(stateStore, pr.localId)) { - alreadyReviewed++; - continue; - } - const ledger = await getAutopilotLedger(stateStore, pr.localId); - if (ledger?.decision === 'skipped' && ledger.autoReviewedUpdatedAt === pr.updatedAt) { - skipDeduped++; - continue; - } - candidates.push(pr); - } - if (candidates.length === 0) { - // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 - logger.info( - { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, - 'autopilot pass: no eligible candidates', - ); - return; - } - - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - await withAgentChat(async (chat) => { - // 批量判定(例外规则来自 AGENTS.md)。 - const { decisions } = await judgeAutopilotBatch(chat, { - candidates: candidates.map((p) => ({ - prLocalId: p.localId, - title: p.title, - description: p.description, - })), - agentsRules: agentContext.files.agents, - }); - const byId = new Map(candidates.map((p) => [p.localId, p] as const)); - // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策待并行编排。 - const toReview: StoredPullRequest[] = []; - for (const d of decisions) { - const pr = byId.get(d.prLocalId); - if (!pr) continue; - if (!d.review) { - // 输出判定 skip 的原因(候选都已过准入闸、非「已评审」,故这里的原因都是 LLM 的领域判定, - // 如分支合并 / 纯依赖升级 — 打出来便于核对「为何没评审这个 PR」)。 - logger.info({ prLocalId: pr.localId, reason: d.reason }, 'autopilot judge skip'); - await writeAutopilotLedger(stateStore, { - prLocalId: pr.localId, - autoReviewedUpdatedAt: pr.updatedAt, - decision: 'skipped', - reason: d.reason, - at: new Date().toISOString(), - }); - continue; - } - toReview.push(pr); - } - // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让工具的并发队列 - // (run-queue maxConcurrency)尽量被填满,而非逐 PR 串行空等。各 PR 写各自的文件,无竞争。 - await Promise.all( - toReview.map(async (pr) => { - // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 - markAgentRunning(pr.localId); - try { - const session = await runReviewForPr(pr, agentContext, chat, undefined, true); - // done:落「评审总结」消息 + 台账(含 verdict)+ 广播会话变更(与手动评审一致)。 - // 失败 / 暂停不落台账 → 无产出,下轮可重试(准入闸 2 用 hasReviewOutput 判,不再靠台账拦)。 - await recordReviewSummaryMessage(pr, session); - } finally { - unmarkAgentRunning(pr.localId); - } - }), - ); - }); - logger.info({ candidates: candidates.length }, 'autopilot pass done'); - } catch (err) { - logger.warn({ err }, 'autopilot pass failed (ignored)'); - } finally { - autopilotBusy = false; - } - })(); - }; - - ipcMain.handle( - 'pragent:cancel', - async ( - _evt, - req: IpcChannels['pragent:cancel']['request'], - ): Promise => { - // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) - const running = active.get(req.runId); - if (running) { - logger.info({ runId: req.runId }, 'pragent run cancel: active'); - running.ac?.abort(); - return { ok: true }; - } - // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) - const idx = waiting.findIndex((q) => q.info.runId === req.runId); - if (idx >= 0) { - const [removed] = waiting.splice(idx, 1); - logger.info({ runId: req.runId, queueLen: waiting.length }, 'pragent run cancel: queued'); - removed!.reject(new Error('queued run cancelled')); - broadcastQueueChanged(); - return { ok: true }; - } - return { ok: false }; - }, - ); - - ipcMain.handle('pragent:queue', (): IpcChannels['pragent:queue']['response'] => ({ - active: [...active.values()].map((q) => q.info), - waiting: waiting.map((q) => q.info), - })); - - ipcMain.handle( - 'pragent:listRuns', - async ( - _evt, - req: IpcChannels['pragent:listRuns']['request'], - ): Promise => - listReviewRunsForPr(stateStore, req.localId, { - limit: req.limit, - beforeId: req.beforeId, - }), - ); - - ipcMain.handle( - 'pragent:getRun', - async ( - _evt, - req: IpcChannels['pragent:getRun']['request'], - ): Promise => - getReviewRun(stateStore, req.localId, req.runId), - ); - ipcMain.handle( - 'pragent:clearRuns', - async ( - _evt, - req: IpcChannels['pragent:clearRuns']['request'], - ): Promise => { - // 清执行历史时一并清掉 Agent 会话(含收尾 summary / 步骤 transcript),否则清空后 - // 重开 PR 仍会从落盘会话恢复出「评审总结」卡片。 - await clearAgentSession(stateStore, req.localId); - // 一并清掉 AutoPilot 台账(评审建议 verdict),并广播 → PR 列表该 PR 的 ★ 徽标即时消失, - // 不残留陈旧评审状态、也不必等下个 poll 重取台账。 - await clearAutopilotLedger(stateStore, req.localId); - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:reviewStatusCleared', { prLocalId: req.localId }); - } - return { cleared: await clearReviewRunsForPr(stateStore, req.localId) }; - }, - ); - - // === M4 草稿 IPC === - // 所有 mutator (create / update / delete) 写盘成功后立刻广播 drafts:changed, - // renderer drafts-store 据此重拉刷新 - - ipcMain.handle( - 'drafts:list', - async ( - _evt, - req: IpcChannels['drafts:list']['request'], - ): Promise => listDrafts(stateStore, req.localId), - ); - - ipcMain.handle( - 'drafts:create', - async ( - _evt, - req: IpcChannels['drafts:create']['request'], - ): Promise => { - // 防御:origin='finding' 必须带 source;origin='manual' 不要 source。 - // 上层 UI 已校验,但 IPC 边界再挡一道避免脏数据进盘 - const { draft, localId } = req; - if (draft.origin === 'finding' && !draft.source) { - throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); - } - if (draft.origin === 'manual' && draft.source) { - throw new Error('drafts:create: origin=manual 不应该传 source'); - } - const created = await createDraft(stateStore, localId, draft); - broadcastDraftsChanged(localId); - return created; - }, - ); - - ipcMain.handle( - 'drafts:update', - async ( - _evt, - req: IpcChannels['drafts:update']['request'], - ): Promise => { - const updated = await updateDraft(stateStore, req.localId, req.draftId, req.patch); - if (updated) broadcastDraftsChanged(req.localId); - return updated; - }, - ); - - ipcMain.handle( - 'drafts:delete', - async ( - _evt, - req: IpcChannels['drafts:delete']['request'], - ): Promise => { - await deleteDraft(stateStore, req.localId, req.draftId); - broadcastDraftsChanged(req.localId); - }, - ); - - ipcMain.handle( - 'drafts:publishBatch', - async ( - _evt, - req: IpcChannels['drafts:publishBatch']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - - // 拉一次当前草稿池:localId → id → draft,下面遍历 draftIds 时按 id 查。 - // 不在循环里反复 listDrafts,避免 PR 草稿量大时 O(N²) IO - const allDrafts = await listDrafts(stateStore, req.localId); - const draftById = new Map(allDrafts.map((d) => [d.id, d])); - - const results: IpcChannels['drafts:publishBatch']['response']['results'] = []; - let anyPublished = false; - for (const draftId of req.draftIds) { - const draft = draftById.get(draftId); - if (!draft) { - results.push({ draftId, ok: false, error: t('drafts.notFound') }); - continue; - } - // 状态守卫:rejected 不发 (用户决断不发)。 - // posted 不再守卫 — 发布成功后本地草稿直接删除,不存 'posted' 历史状态, - // 调用方传过来的 draftId 在 listDrafts 找不到时已经被前面 `if (!draft)` 兜住 - if (draft.status === 'rejected') { - results.push({ draftId, ok: false, error: t('drafts.rejected') }); - continue; - } - try { - // ReviewDraftAnchor → PrCommentAnchor 转换: - // - draft.anchor 没有 lineType (草稿创建时不知道这一行的 diff 角色), - // 按 side 做保守映射:new→added / old→removed。meebox 的草稿大多锚到 - // 变更行 (finding 来自 /review 的 issue + DraftZone hover '+' 也只对 - // 变更行可见),context 行评论场景极少。命中 context 时 Bitbucket 回 400, - // 错误会被 catch 收到 results 里给用户看 - // - 多行 (endLine > startLine) 在 Bitbucket REST 里无法表达 (anchor.line 是单 - // 行)。落到 endLine 而不是 startLine:评论会出现在标注范围**下方**, - // 不打断用户从上往下阅读时已经看过的代码上下文。renderer 端 DraftZone - // 仍按 startLine 渲染 (跟 finding/AI 建议触发位置一致),发布完远端 - // 评论会自然显示在 endLine —— 这两种位置都不影响"阅读上下文" 的初衷 - const posted = await adapter.publishInlineComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - { - path: draft.anchor.path, - line: draft.anchor.endLine, - side: draft.anchor.side, - lineType: draft.anchor.side === 'old' ? 'removed' : 'added', - }, - draft.body, - ); - // 发布成功 = 本地草稿使命完成,直接删掉保持草稿池干净。远端 Bitbucket 评论 - // 会通过下面的 force-refresh comments 拉回,UI 上由 CommentZone 承接显示, - // 不需要本地再留一份 'posted' 副本造成重复 (跟远端评论 zone 视觉打架) - await deleteDraft(stateStore, req.localId, draftId); - anyPublished = true; - results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - logger.warn( - { localId: req.localId, draftId, err: msg }, - 'drafts:publishBatch: single draft failed', - ); - results.push({ draftId, ok: false, error: msg }); - } - } - - // 整批跑完统一广播 — drafts 列表更新刷 DraftZone status chip + FindingCard - broadcastDraftsChanged(req.localId); - - // 至少有一条发成功 → force-refresh Bitbucket 评论:清缓存 + 广播 comments:changed - // 让 CommentsPanel / DiffView 内嵌评论立即看到自己刚发的,不用等下一轮 poller - if (anyPublished) { - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - } - return { results }; - }, - ); - - ipcMain.handle( - 'config:setReposDir', - async (_evt, req: IpcChannels['config:setReposDir']['request']): Promise => { - const next = { - ...bootstrap.config, - workspace: { - ...bootstrap.config.workspace, - repos_dir: req.reposDir, - }, - }; - await writeConfig(bootstrap.paths.configFile, next); - logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); - }, - ); - - ipcMain.handle( - 'config:setLanguage', - async (_evt, req: IpcChannels['config:setLanguage']['request']): Promise => { - const next = { ...bootstrap.config, language: req.language }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存同步 + 主进程 i18n 即时切换(新 dialog/错误文案与下次 pragent:run 的响应语言随之)。 - bootstrap.config.language = req.language; - setMainLanguage(req.language); - logger.info({ language: req.language }, 'language config updated'); - }, - ); - - ipcMain.handle( - 'config:setAgent', - async (_evt, req: IpcChannels['config:setAgent']['request']): Promise => { - const next = { ...bootstrap.config, agent: req.agent }; - await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.agent = req.agent; - logger.info({ agent: req.agent }, 'agent config updated'); - }, - ); - - ipcMain.handle( - 'agent:setAutopilotEnabled', - async (_evt, req: IpcChannels['agent:setAutopilotEnabled']['request']): Promise => { - const was = bootstrap.config.agent.autopilot.enabled; - const agent = { - ...bootstrap.config.agent, - autopilot: { ...bootstrap.config.agent.autopilot, enabled: req.enabled }, - }; - await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, agent }); - bootstrap.config.agent = agent; - logger.info({ enabled: req.enabled }, 'autopilot toggled'); - // 关 → 开:立即触发一次 poll(刷新 PR 列表 / 状态),其 onTick 即按准入规则评估并按需开评审, - // 不必等下个轮询周期。 - if (req.enabled && !was) { - void poller.tick(); - } - }, - ); - - ipcMain.handle( - 'agent:autopilotLedgers', - async ( - _evt, - req: IpcChannels['agent:autopilotLedgers']['request'], - ): Promise => { - const out: Record = {}; - for (const id of req.localIds) { - const ledger = await getAutopilotLedger(stateStore, id); - if (ledger?.decision === 'review' && ledger.recommendation) { - out[id] = ledger.recommendation; - } - } - return out; - }, - ); - - ipcMain.handle( - 'rules:matchForPr', - async ( - _evt, - req: IpcChannels['rules:matchForPr']['request'], - ): Promise => { - // ask 工具不接规则 (问答自由形式,没什么"规约"可应用) - if (req.tool === 'ask') return null; - const pr = await findPrOrThrow(req.localId); - const rules = await loadAgentRules(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), - }); - const matched = pickMatchingRule(rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: req.tool, - }); - if (!matched) return null; - return { - id: matched.id, - filePath: matched.filePath, - priority: matched.priority, - tools: [...matched.tools], - instructions: matched.instructions, - }; - }, - ); - - ipcMain.handle( - 'config:setLlm', - async (_evt, req: IpcChannels['config:setLlm']['request']): Promise => { - const next = { ...bootstrap.config, llm: req.llm }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存中 config 同步更新,下一次 pragent:run 立刻用新值(不等重启) - bootstrap.config.llm = req.llm; - logger.info( - { - profileCount: req.llm.profiles.length, - activeId: req.llm.active_id, - }, - 'llm config updated', - ); - }, - ); - - ipcMain.handle( - 'config:setConnections', - async (_evt, req: IpcChannels['config:setConnections']['request']): Promise => { - const next = { - ...bootstrap.config, - connections: req.connections, - active_connection_id: req.active_connection_id, - }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存 config 同步 + 热重建 adapter/poller,连接变更即时生效(不等重启) - bootstrap.config.connections = req.connections; - bootstrap.config.active_connection_id = req.active_connection_id; - await reconfigureConnections(); - // 立刻 poll 一轮,让启用 / 切换的连接 PR 马上出现(active 为空则空操作) - void poller.tick(); - logger.info( - { count: req.connections.length, activeId: req.active_connection_id }, - 'connections config updated (hot-reloaded)', - ); - }, - ); - - ipcMain.handle( - 'config:setProxy', - async (_evt, req: IpcChannels['config:setProxy']['request']): Promise => { - const next = { ...bootstrap.config, proxy: req.proxy }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存同步 + 热重建 adapter(REST fetch 用上新代理);git/pr-agent 出口读最新配置无需重建 - bootstrap.config.proxy = req.proxy; - await reconfigureConnections(); - logger.info( - { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, - 'proxy config updated (hot-reloaded)', - ); - }, - ); - - ipcMain.handle( - 'config:testProxy', - async ( - _evt, - req: IpcChannels['config:testProxy']['request'], - ): Promise => { - return testProxyConnectivity(req.proxy); - }, - ); - - ipcMain.handle( - 'config:testConnection', - async ( - _evt, - req: IpcChannels['config:testConnection']['request'], - ): Promise => { - // 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason - try { - return await buildDraftAdapter( - req.base_url, - req.token, - bootstrap.config.proxy, - req.kind, - ).ping(); - } catch (e) { - return { ok: false, reason: e instanceof Error ? e.message : String(e) }; - } - }, - ); - - ipcMain.handle( - 'config:autosaveDraft', - async (_evt, req: IpcChannels['config:autosaveDraft']['request']): Promise => { - // 只写 config.yaml(含 base 非编辑字段),**不更新内存 config、不 reconfigure**: - // 持久化防丢失但不生效。重启读文件 或 点底栏「保存」走 config:setConnections/setLlm 才应用。 - const next = { - ...bootstrap.config, - connections: req.connections, - active_connection_id: req.active_connection_id, - llm: req.llm, - }; - await writeConfig(bootstrap.paths.configFile, next); - logger.info( - { connections: req.connections.length, profiles: req.llm.profiles.length }, - 'connections/llm draft autosaved to config.yaml (not applied)', - ); - }, - ); - - ipcMain.handle( - 'config:setPoller', - async (_evt, req: IpcChannels['config:setPoller']['request']): Promise => { - // 防御性 clamp 到 60~900 整数(UI 已限制,这里兜底) - const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); - const next = { - ...bootstrap.config, - poller: { ...bootstrap.config.poller, interval_seconds: seconds }, - }; - await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.poller.interval_seconds = seconds; - poller.setIntervalSeconds(seconds); // 热替换定时器,无需重启 - logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); - }, - ); - - logger.debug('IPC handlers registered'); + ipcMain.handle('rules:matchForPr', agent.matchRuleForPr); // 查 PR 命中的规则 + ipcMain.handle('agent:run', agent.runReview); // 一键评审编排(describe→review→总结) + ipcMain.handle('agent:ask', agent.runPlanning); // 自由规划 Agent(对话即委派) + ipcMain.handle('agent:enqueueMessage', agent.enqueueMessage); // 运行中追加用户消息(入队 / 起新轮) + ipcMain.handle('agent:stop', agent.stopAgent); // 停止某 PR 的 Agent 运行 + ipcMain.handle('agent:getSession', agent.getSession); // 读已落盘评审会话 + ipcMain.handle('agent:getConversation', agent.getConversation); // 读多轮对话消息 + ipcMain.handle('agent:getTranscript', agent.getTranscript); // 读 Agent 过程步骤 + ipcMain.handle('agent:autopilotLedgers', agent.getAutopilotLedgers); // 批量读 AutoPilot 评审台账 + ipcMain.handle('pragent:run', agent.runPragent); // 触发一次 pr-agent run(入队) + ipcMain.handle('pragent:cancel', agent.cancelPragent); // 取消一个 run + ipcMain.handle('pragent:queue', agent.getQueue); // 队列快照(active + waiting) + ipcMain.handle('pragent:listRuns', agent.listRuns); // 历史 run 列表(游标分页) + ipcMain.handle('pragent:getRun', agent.getRun); // 单条 run 查询 + ipcMain.handle('pragent:clearRuns', agent.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 + ipcMain.handle('pragent:deleteRun', agent.deleteRun); // 删除单条 run 记录 + + base.logger.debug('IPC handlers registered'); return { /** @@ -2230,208 +133,10 @@ export function registerIpcHandlers({ * onAbort → killTree(进程树级杀),连带终止 python 及其 litellm 等孙进程,避免孤儿进程锁住 * 安装目录导致升级安装失败。返回被中止的 run 数,供调用方决定是否需要短暂等待 taskkill 跑完。 */ - abortAllActiveRuns: () => { - let n = 0; - for (const item of active.values()) { - item.ac?.abort(); - n++; - } - return n; - }, - /** 每次 poll tick 由 index.ts 调用:满足开关 + 最小间隔 + 候选时跑一遍 AutoPilot pass。 */ - runAutopilotIfDue, + abortAllActiveRuns: () => runQueue.abortAllActiveRuns(), + /** 每次 poll tick 由 index.ts 调用:满足开关 + 候选时跑一遍 AutoPilot pass。 */ + runAutopilotIfDue: () => orchestrator.runAutopilotIfDue(), /** 每次 poll tick 由 index.ts 调用:终止已被移除 / purge 的 PR 上仍在执行的 agent 操作。 */ - terminateAgentsForGonePrs: () => void terminateAgentsForGonePrs(), - }; -} - -/** - * 把 config.language (ISO locale) 翻成自然语言 prompt directive,注入到 pr-agent - * 各 tool 的 EXTRA_INSTRUCTIONS。 - * - * CONFIG__RESPONSE_LANGUAGE 对 /describe /review 已经够用 (内嵌在它们的 prompt - * template),但 /ask 不严格遵守;显式 prompt 强化所有 tool,尤其覆盖 /ask + 表格 - * 类输出的标题 / 列名 / 段落标记。 - * - * 英文 (en-US) 返回空串,避免给 LLM 加不必要的提示。其他未知 locale 返回空保留 - * pr-agent 原行为。 - */ -// litellm usage 哨兵行前缀(与 sitecustomize.py 的 _emit 保持一致)。 -const USAGE_SENTINEL = '@@MEEBOX_USAGE@@'; - -interface UsageAcc { - prompt: number; - completion: number; - total: number; - calls: number; - any: boolean; -} - -/** - * 解析一行 stderr:若含 usage 哨兵(`@@MEEBOX_USAGE@@ {json}`,sitecustomize 注入)则累加到 - * acc 并返回 true(调用方据此吞掉该行、不转发给 renderer / 不入日志)。普通行返回 false。 - * 坏 JSON 也返回 true(仍吞掉,避免漏进实时日志),只是不计数。容错优先。 - */ -function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { - const i = line.indexOf(USAGE_SENTINEL); - if (i < 0) return false; - try { - const r = JSON.parse(line.slice(i + USAGE_SENTINEL.length).trim()) as { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; - acc.calls += 1; - if (typeof r.prompt_tokens === 'number') { - acc.prompt += r.prompt_tokens; - acc.any = true; - } - if (typeof r.completion_tokens === 'number') { - acc.completion += r.completion_tokens; - acc.any = true; - } - if (typeof r.total_tokens === 'number') { - acc.total += r.total_tokens; - acc.any = true; - } - } catch { - // 坏哨兵行:仍吞掉,不计数 - } - return true; -} - -/** 累加器 → TokenUsage;无任何有效数据返回 undefined(未捕获到,如非 embedded / 流式 / 未调 LLM)。 */ -function finalizeUsage(acc: UsageAcc): TokenUsage | undefined { - if (!acc.any) return undefined; - return { - promptTokens: acc.prompt, - completionTokens: acc.completion, - // 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底 - totalTokens: acc.total || acc.prompt + acc.completion, - calls: acc.calls, - }; -} - -/** - * 持久化前从 stderr 去掉 usage 哨兵行:onLine 实时已拦截不转发,但 exec 内部把全量 stderr - * 累加进 result.stderr(含哨兵),落盘前清掉这些噪声行。 - */ -function stripUsageSentinels(stderr: string | undefined): string | undefined { - if (!stderr) return stderr; - return stderr - .split('\n') - .filter((l) => !l.includes(USAGE_SENTINEL)) - .join('\n'); -} - -function languageDirectiveFor(lang: string): string { - const norm = lang.toLowerCase(); - if (norm.startsWith('zh-cn') || norm === 'zh') { - return 'Respond in Simplified Chinese (简体中文). All section labels, table headers, column names, headings, and content MUST be in Chinese — do not leave any English template strings untranslated.'; - } - if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { - return 'Respond in Traditional Chinese (繁體中文). All section labels, table headers, column names, headings, and content MUST be in Chinese.'; - } - if (norm.startsWith('ja')) { - return 'Respond in Japanese (日本語). All section labels, table headers, column names, headings, and content MUST be in Japanese — do not leave any English template strings untranslated.'; - } - if (norm.startsWith('de')) { - return 'Respond in German (Deutsch). All section labels, table headers, column names, headings, and content MUST be in German — do not leave any English template strings untranslated.'; - } - return ''; -} - -/** - * /ask 专用:把语言要求作为「问题末尾」的硬性指令,**用目标语言书写本身**(最能促使模型切换到该 - * 语言作答)。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量英文 diff - * 盖过,故在 user turn 末尾(近因位置)再要求一次。en-US / 未知 locale 返回空串(默认即英文)。 - */ -function askLanguageSuffixFor(lang: string): string { - const norm = lang.toLowerCase(); - if (norm.startsWith('zh-cn') || norm === 'zh') { - return '请用简体中文回答整个回复(包括所有解释、说明与结论)。代码、标识符、文件路径保留原样,但所有叙述文字必须是简体中文,不要用英文作答。'; - } - if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { - return '請用繁體中文回答整個回覆(包括所有解釋、說明與結論)。程式碼、識別符、檔案路徑保留原樣,但所有敘述文字必須是繁體中文,不要用英文作答。'; - } - if (norm.startsWith('ja')) { - return '回答全体を日本語で記述してください(説明・結論を含む)。コード・識別子・ファイルパスはそのまま残し、説明文はすべて日本語にしてください。英語で回答しないでください。'; - } - if (norm.startsWith('de')) { - return 'Bitte antworte vollständig auf Deutsch (einschließlich aller Erklärungen und Schlussfolgerungen). Code, Bezeichner und Dateipfade bleiben unverändert, aber der gesamte erläuternde Text muss auf Deutsch sein. Antworte nicht auf Englisch.'; - } - return ''; -} - -/** - * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。 - * - * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version - * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) - * - canEdit: author.name === 当前 PAT 用户 && 有 version - * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) - * - * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 - * 自己比对 author / version / replies,链路最短最稳。 - */ -function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { - const me = adapter.getCurrentUser(); - if (!me) { - return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); - } - // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 - // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 - const noDeleteWithReplies = adapter.capabilities().commentOptimisticLock; - return setOwnershipRecursive(comments, (c) => { - const isMine = c.author.name === me.name; - const hasVersion = typeof c.version === 'number'; - return { - canDelete: isMine && hasVersion && (!noDeleteWithReplies || c.replies.length === 0), - canEdit: isMine && hasVersion, - }; - }); -} - -function setOwnershipRecursive( - comments: PrComment[], - judge: (c: PrComment) => { canDelete: boolean; canEdit: boolean }, -): PrComment[] { - return comments.map((c) => { - const flags = judge(c); - return { - ...c, - canDelete: flags.canDelete, - canEdit: flags.canEdit, - replies: setOwnershipRecursive(c.replies, judge), - }; - }); -} - -function buildAppInfo(bootstrap: BootstrapResult): AppInfo { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - nodeVersion: process.versions.node, - platform: process.platform, - firstRun: bootstrap.firstRun, + terminateAgentsForGonePrs: () => void orchestrator.terminateAgentsForGonePrs(), }; } - -function buildConnectionSummaries( - bootstrap: BootstrapResult, - adapters: readonly BuiltAdapter[], -): ConnectionSummary[] { - // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 - const activeId = bootstrap.config.active_connection_id; - return adapters - .filter(({ connectionId }) => connectionId === activeId) - .map(({ connectionId, adapter }) => { - const conn = bootstrap.config.connections.find((c) => c.id === connectionId); - return { - connectionId, - displayName: conn?.display_name ?? connectionId, - user: adapter.getCurrentUser(), - capabilities: adapter.capabilities(), - }; - }); -} diff --git a/apps/desktop/src/main/services/agent/flows/autopilot.ts b/apps/desktop/src/main/services/agent/flows/autopilot.ts new file mode 100644 index 00000000..5e90c267 --- /dev/null +++ b/apps/desktop/src/main/services/agent/flows/autopilot.ts @@ -0,0 +1,161 @@ +import { + type BranchMergeVerdict, + classifyBranchMerge, + judgeAutopilotBatch, + loadAgentContext, + type ReviewPlan, +} from '@meebox/agent'; +import { + getAutopilotLedger, + hasReviewOutput, + listStoredPullRequests, + writeAutopilotLedger, +} from '@meebox/poller'; +import type { PrCommit, StoredPullRequest } from '@meebox/shared'; +import type { OrchestratorRuntime } from '../runtime.js'; +import { runReviewForPr } from './review.js'; + +/** + * 跑一遍 AutoPilot pass(busy 锁置位 / 复位包住全程)。仅由 Orchestrator.runAutopilotIfDue 通过准入后触发。 + * 候选准入(硬性门控,自上而下):① 仅「待我评审 + 待处理」;② 已有 describe/review 产出(成功 / 进行中) + * → 已评审过 / 评审中,排除;③ 本版本已被判 skip 的台账去重。再按 batch_size 截断,批量判定后并行编排评审。 + */ +export async function autopilotPass(runtime: OrchestratorRuntime): Promise { + const { bootstrap, stateStore, effectiveAgentDir, logger } = runtime.ctx; + const ap = bootstrap.config.agent.autopilot; + runtime.setAutopilotBusy(true); + try { + const prs = await listStoredPullRequests(stateStore); + const candidates: StoredPullRequest[] = []; + // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 + let reviewReqPending = 0; // 命中「待我评审 + 待处理」 + let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 + let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 + for (const pr of prs) { + if (candidates.length >= ap.batch_size) break; + if (!pr.discoveryFilters.includes('review-requested')) continue; + if (pr.localStatus !== 'pending') continue; + reviewReqPending++; + if (await hasReviewOutput(stateStore, pr.localId)) { + alreadyReviewed++; + continue; + } + const ledger = await getAutopilotLedger(stateStore, pr.localId); + if (ledger?.decision === 'skipped' && ledger.autoReviewedUpdatedAt === pr.updatedAt) { + skipDeduped++; + continue; + } + candidates.push(pr); + } + if (candidates.length === 0) { + // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 + logger.info( + { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, + 'autopilot pass: no eligible candidates', + ); + return; + } + + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + // 第一步 judge 的背景输入:逐候选判「纯分支合并」。判定**以实际提交结构为准**——拉一次 commits API + // 看「提交是否全为 merge」;分支名只作 sourceMainline 背景信号、不单独定论。并行跑,整体约一轮 round-trip。 + // 失败不阻断(无 commits → inconclusive、按非合并处理,仍带 sourceMainline 信号交 judge 权衡)。 + const branchMergeByPr = new Map(); + await Promise.all( + candidates.map(async (p) => { + const sourceBranch = p.sourceRef.displayId; + const targetBranch = p.targetRef.displayId; + let commits: PrCommit[] | undefined; + try { + commits = await runtime.ctx.pr + .adapterFor(p) + ?.listPullRequestCommits(p.repo, p.remoteId); + } catch (err) { + logger.debug({ err, prLocalId: p.localId }, 'branch-merge commits check failed (ignored)'); + } + branchMergeByPr.set( + p.localId, + classifyBranchMerge({ sourceBranch, targetBranch, commits: commits ?? undefined }), + ); + }), + ); + + await runtime.withAgentChat(async (chat) => { + // 批量判定(例外规则来自 AGENTS.md;分支信息 + 分支合并信号作背景输入)。 + const { decisions } = await judgeAutopilotBatch(chat, { + candidates: candidates.map((p) => { + const v = branchMergeByPr.get(p.localId); + return { + prLocalId: p.localId, + title: p.title, + description: p.description, + sourceBranch: p.sourceRef.displayId, + targetBranch: p.targetRef.displayId, + branchMerge: v?.isBranchMerge, + sourceMainline: v?.sourceMainline, + }; + }), + agentsRules: agentContext.files.agents, + }); + const byId = new Map(candidates.map((p) => [p.localId, p] as const)); + // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策(连同其执行计划)待并行编排。 + const toReview: Array<{ pr: StoredPullRequest; plan?: ReviewPlan }> = []; + for (const d of decisions) { + const pr = byId.get(d.prLocalId); + if (!pr) continue; + // 每条决策都记日志(含 review/skip + 原因 + 计划 + 分支合并信号),便于排查「judge 是否在按规则跑」。 + logger.info( + { + prLocalId: pr.localId, + review: d.review, + reason: d.reason, + plan: d.plan?.steps, + branchMerge: branchMergeByPr.get(pr.localId)?.isBranchMerge ?? false, + }, + 'autopilot judge decision', + ); + if (!d.review) { + await writeAutopilotLedger(stateStore, { + prLocalId: pr.localId, + autoReviewedUpdatedAt: pr.updatedAt, + decision: 'skipped', + reason: d.reason, + at: new Date().toISOString(), + }); + continue; + } + // d.plan 省略 → 微流程走默认全集;规则驱动计划的注入点(见 JudgeDecision.plan)。 + toReview.push({ pr, plan: d.plan }); + } + // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让 run-queue 并发尽量被填满。 + await Promise.all( + toReview.map(async ({ pr, plan }) => { + // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 + runtime.markRunning(pr.localId); + try { + const session = await runReviewForPr( + runtime, + pr, + agentContext, + chat, + undefined, + true, + plan, + ); + // done:落「评审总结」消息 + 台账(含 verdict)+ 广播(与手动评审一致)。失败 / 暂停不落台账。 + await runtime.recordReviewSummaryMessage(pr, session); + } finally { + runtime.unmarkRunning(pr.localId); + } + }), + ); + }); + logger.info({ candidates: candidates.length }, 'autopilot pass done'); + } catch (err) { + logger.warn({ err }, 'autopilot pass failed (ignored)'); + } finally { + runtime.setAutopilotBusy(false); + } +} diff --git a/apps/desktop/src/main/services/agent/flows/planning.ts b/apps/desktop/src/main/services/agent/flows/planning.ts new file mode 100644 index 00000000..4c6ab300 --- /dev/null +++ b/apps/desktop/src/main/services/agent/flows/planning.ts @@ -0,0 +1,107 @@ +import { appendAgentNotes, buildToolCatalog, loadAgentContext } from '@meebox/agent'; +import type { AgentContext } from '@meebox/agent'; +import { appendAgentMessage, updateAgentSession } from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import { AppError, ERROR_CODES, type AgentSession, type StoredPullRequest } from '@meebox/shared'; +import { getMainLanguage } from '../../../i18n/index.js'; +import { runPlanning } from '../planning.js'; +import type { AgentChat, OrchestratorRuntime } from '../runtime.js'; + +/** 自由规划编排(agent:ask):现读现装配上下文 + 注册 AbortController + 标记执行中,跑规划 ReAct。 */ +export async function planningFlow( + runtime: OrchestratorRuntime, + pr: StoredPullRequest, + question: string, + referencedContext?: string, +): Promise { + const { getPrAgentBridge, effectiveAgentDir, logger } = runtime.ctx; + if (!getPrAgentBridge()) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + const ac = new AbortController(); + runtime.registerController(pr.localId, ac); + runtime.markRunning(pr.localId); + // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 + logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); + try { + const session = await runtime.withAgentChat( + (chat) => runPlanningForPr(runtime,pr, question, agentContext, chat, ac.signal, referencedContext), + ac.signal, + ); + logger.info( + { + prLocalId: pr.localId, + status: session.status, + steps: session.stepCount, + terminationReason: session.terminationReason, + }, + 'agent chat done', + ); + return session; + } finally { + runtime.clearController(pr.localId); + runtime.unmarkRunning(pr.localId); + } +} + +/** 对一个 PR 跑自由规划(组装 PlanningDeps + 调 runner):含中途输入 drain、计划持久化、主动记忆落盘。 */ +export function runPlanningForPr( + runtime: OrchestratorRuntime, + pr: StoredPullRequest, + userRequest: string, + agentContext: AgentContext, + chat: AgentChat, + signal: AbortSignal, + referencedContext?: string, +): Promise { + const { bootstrap, effectiveAgentDir, logger } = runtime.ctx; + const agentCfg = bootstrap.config.agent; + const matchedRule = pickMatchingRule(agentContext.rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: 'review', + }); + return runPlanning(pr, userRequest, { + stateStore: runtime.ctx.stateStore, + enqueueRun: (p, tool, question) => runtime.runQueue.enqueuePragentRun(p, tool, question, 'agent'), + referencedContext, + chat, + agentContext, + toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), + matchedRule, + language: getMainLanguage(), + maxSteps: agentCfg.max_steps, + signal, + onStep: (sessionId, step) => runtime.emitStep(pr, sessionId, step), + // 中途输入转向:planner 每轮取出排队消息时在此落盘进会话 + 广播刷新(即时显示为用户气泡), + // planner 再把它们并入当轮 progress、据最新指令重排下一步。 + drainPendingInput: async () => { + const msgs = runtime.takePending(pr.localId); + for (const m of msgs) { + await appendAgentMessage(runtime.ctx.stateStore, pr.localId, { role: 'user', content: m }); + } + if (msgs.length) runtime.ctx.broadcast('agent:conversationChanged', { prLocalId: pr.localId }); + return msgs; + }, + // 计划(todo)更新:planner 给出 plan 即持久化进会话 + 广播刷新计划面板;切 PR / 重启经 + // agent:getSession 水合。 + recordPlan: async (todo) => { + await updateAgentSession(runtime.ctx.stateStore, pr.localId, { todo }); + runtime.ctx.broadcast('agent:planUpdated', { prLocalId: pr.localId, todo }); + }, + // 持久化 Agent 主动记下的非隐私条目到当前 Agent 目录的各可写文件(USER/MEMORY/AGENTS); + // SOUL.md 永不写。下一轮 loadAgentContext 现读即生效(跨会话记忆)。 + recordMemory: async (notes) => { + const dir = effectiveAgentDir(); + for (const kind of ['user', 'memory', 'agents'] as const) { + const added = await appendAgentNotes(dir, kind, notes[kind]).catch((err: unknown) => { + logger.warn({ err, kind }, 'record agent memory failed'); + return [] as string[]; + }); + if (added.length) logger.info({ kind, added }, 'agent memory recorded'); + } + }, + }); +} diff --git a/apps/desktop/src/main/services/agent/flows/review.ts b/apps/desktop/src/main/services/agent/flows/review.ts new file mode 100644 index 00000000..6153eea6 --- /dev/null +++ b/apps/desktop/src/main/services/agent/flows/review.ts @@ -0,0 +1,107 @@ +import { buildToolCatalog, loadAgentContext } from '@meebox/agent'; +import type { AgentContext, ReviewPlan } from '@meebox/agent'; +import { addFindingClosure } from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import { AppError, ERROR_CODES, type AgentSession, type StoredPullRequest } from '@meebox/shared'; +import { getMainLanguage } from '../../../i18n/index.js'; +import { runReview } from '../review.js'; +import type { AgentChat, OrchestratorRuntime } from '../runtime.js'; +import { planningFlow } from './planning.js'; + +/** + * 手动评审编排(agent:run):现读现装配上下文 + 注册 AbortController + 标记执行中,跑评审微流程,收尾把 + * 「评审总结」落多轮对话与台账。评审微流程是固定模板、无法中途转向:跑完后把运行期间排队的用户消息作为 + * 一轮自由规划接续处理(fire-and-forget,本次评审会话照常返回)。 + */ +export async function reviewFlow( + runtime: OrchestratorRuntime, + pr: StoredPullRequest, +): Promise { + const { getPrAgentBridge, effectiveAgentDir, logger } = runtime.ctx; + if (!getPrAgentBridge()) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); + // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存。 + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 + const ac = new AbortController(); + runtime.registerController(pr.localId, ac); + runtime.markRunning(pr.localId); + logger.info({ prLocalId: pr.localId }, 'agent review start (manual)'); + let session: AgentSession; + try { + session = await runtime.withAgentChat( + (chat) => runReviewForPr(runtime, pr, agentContext, chat, ac.signal), + ac.signal, + ); + logger.info( + { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, + 'agent review done', + ); + // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 + await runtime.recordReviewSummaryMessage(pr, session); + } finally { + runtime.clearController(pr.localId); + runtime.unmarkRunning(pr.localId); + } + // 评审微流程无法中途转向:跑完后把排队的用户消息作为一轮自由规划接续处理(fire-and-forget)。 + const pending = runtime.takePending(pr.localId); + if (pending.length) { + void planningFlow(runtime, pr, pending.join('\n\n')).catch((err: unknown) => { + logger.warn({ err, prLocalId: pr.localId }, 'post-review planning (queued input) failed'); + }); + } + return session; +} + +/** + * 对一个 PR 跑评审微流程(共用 enqueue 队列 / 持久化 / 步骤广播)。手动评审与 AutoPilot 背景评审共用。 + * 编排派发的 run 走 agent 低优先级泳道;修改类工具按 grants 门控(红线见 buildToolCatalog)。 + */ +export function runReviewForPr( + runtime: OrchestratorRuntime, + pr: StoredPullRequest, + agentContext: AgentContext, + chat: AgentChat, + signal?: AbortSignal, + autopilot = false, + /** 评审执行计划(仅 AutoPilot 按规则注入);省略 → 微流程走默认全集。 */ + plan?: ReviewPlan, +): Promise { + const agentCfg = runtime.ctx.bootstrap.config.agent; + const matchedRule = pickMatchingRule(agentContext.rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: 'review', + }); + return runReview(pr, { + stateStore: runtime.ctx.stateStore, + // 编排派发的 run 走 agent 低优先级泳道:用户随时点 /review 会插到它们之前。复评 /ask 携引用上下文 + 前向链。 + enqueueRun: (p, tool, question, referencedContext, referencedFinding) => + runtime.runQueue.enqueuePragentRun( + p, + tool, + question, + 'agent', + referencedContext, + referencedFinding, + ), + // PR3:复评裁决 replace/drop → 关闭被取代的原 review finding(写 FindingClosure + 广播刷新卡片)。 + closeFinding: async (p, call) => { + await addFindingClosure(runtime.ctx.stateStore, p.localId, call); + runtime.ctx.broadcast('findingClosures:changed', { localId: p.localId }); + }, + chat, + agentContext, + matchedRule, + language: getMainLanguage(), + toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), + plan, + maxFollowupAsks: agentCfg.autopilot.max_followup_asks, + summaryMaxChars: agentCfg.summary_max_chars, + onStep: (sessionId, step) => runtime.emitStep(pr, sessionId, step), + signal, + autopilot, + }); +} diff --git a/apps/desktop/src/main/services/agent/index.ts b/apps/desktop/src/main/services/agent/index.ts new file mode 100644 index 00000000..effdb345 --- /dev/null +++ b/apps/desktop/src/main/services/agent/index.ts @@ -0,0 +1,5 @@ +/** + * Agent 编排域:会话 Agent(手动评审 / 自由规划 / AutoPilot)的主进程接线。Orchestrator 为对外能力入口, + * review / planning(微流程与规划的主进程 runner)+ labels(i18n 文案注入)为域内协作件,不外暴露。 + */ +export { Orchestrator } from './orchestrator.js'; diff --git a/apps/desktop/src/main/services/agent/labels.ts b/apps/desktop/src/main/services/agent/labels.ts new file mode 100644 index 00000000..510ccbda --- /dev/null +++ b/apps/desktop/src/main/services/agent/labels.ts @@ -0,0 +1,40 @@ +import type { AgentStepLabels } from '@meebox/agent'; +import { t } from '../../i18n/index.js'; + +/** + * 把 agent 步骤展示文案 / 总结骨架 / 中止原因从主进程 i18n 资源(locales/*.json 的 `agent.*`)解析出来, + * 注入纯逻辑的 agent 编排器——agent 内仅留 en-US 兜底,多语言译文统一在 i18n 资源维护。 + * 经会话语言(= getMainLanguage,t() 当前语言)解析,与 UI 一致;步骤文案在生成时落地、随 transcript 持久化。 + */ + +/** 从 i18n 资源构造步骤展示文案(judgeSevere 走 i18next 复数 count)。 */ +export function buildStepLabels(): AgentStepLabels { + return { + describeReview: t('agent.steps.describeReview'), + improve: t('agent.steps.improve'), + judge: t('agent.steps.judge'), + judgeSevere: (n) => t('agent.steps.judgeSevere', { count: n }), + judgeNone: t('agent.steps.judgeNone'), + summary: t('agent.steps.summary'), + rejectedPrefix: t('agent.steps.rejectedPrefix'), + }; +} + +/** 从 i18n 资源构造评审总结三段骨架标题(概述 / 关键发现 / 建议)。 */ +export function buildSummarySections(): [string, string, string] { + return [ + t('agent.summarySections.overview'), + t('agent.summarySections.findings'), + t('agent.summarySections.suggestions'), + ]; +} + +/** + * 把 agent 返回的中止原因稳定 code 映射为本地化文案:'aborted' / 'max_steps' 经 i18n 资源;其它(failed + * 分支的具体错误信息)原样返回。落盘前调用,使 session.terminationReason 即为目标语言文本(渲染层逐字显示)。 + */ +export function mapTerminationReason(code: string | undefined): string | undefined { + if (code === 'aborted') return t('agent.termination.aborted'); + if (code === 'max_steps') return t('agent.termination.maxSteps'); + return code; +} diff --git a/apps/desktop/src/main/services/agent/orchestrator.ts b/apps/desktop/src/main/services/agent/orchestrator.ts new file mode 100644 index 00000000..6b9521cb --- /dev/null +++ b/apps/desktop/src/main/services/agent/orchestrator.ts @@ -0,0 +1,240 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { appendAgentMessage, listStoredPullRequests, writeAutopilotLedger } from '@meebox/poller'; +import { buildChatEnv } from '@meebox/pr-agent-bridge'; +import { + AppError, + ERROR_CODES, + type AgentSession, + type AgentStep, + type StoredPullRequest, +} from '@meebox/shared'; +import { getMainLanguage } from '../../i18n/index.js'; +import { resolveActiveLlmProfile } from '../../utils/agent.js'; +import { buildProxyEnv } from '../../utils/proxy.js'; +import type { ServiceContext } from '../context.js'; +import type { RunQueue } from '../pr-agent/index.js'; +import { accumulateUsageSentinel, finalizeUsage, newUsageAcc } from '../pr-agent/usage.js'; +import { autopilotPass } from './flows/autopilot.js'; +import { planningFlow } from './flows/planning.js'; +import { reviewFlow } from './flows/review.js'; +import type { AgentChat, OrchestratorRuntime } from './runtime.js'; + +/** + * Agent 编排服务(有状态协调器):手动评审(agent:run)、自由规划(agent:ask)、AutoPilot 后台预评审, + * 以及随 poll tick 清理已消失 PR 的在跑操作。运行态(每 PR 的 AbortController、「执行中」集合、AutoPilot + * busy 锁、中途输入队列)是实例可变状态,故以 class 封装并实现 OrchestratorRuntime——把状态访问 + 共享 + * helper(withAgentChat / 收尾落地 / 步骤广播等)暴露给按「一任务一文件」拆分的各 flow(见 ./flows)。 + */ +export class Orchestrator implements OrchestratorRuntime { + // 编排 Agent 每 PR 至多一个在跑,AbortController 供 agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 + private readonly agentControllers = new Map(); + // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。手动 run/ask 与 + // AutoPilot 后台评审一并计入,让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 + private readonly runningAgentPrs = new Set(); + // Agent 编排层全局单并发:一次只跑一遍 AutoPilot pass(busy 锁),防止上一遍未完又叠跑。 + private autopilotBusy = false; + // 中途输入转向:每 PR 一个待处理用户消息队列(运行中追加 → 入队,下一周期 drain 注入)。 + private readonly pendingInputByPr = new Map(); + + constructor( + readonly ctx: ServiceContext, + readonly runQueue: RunQueue, + ) {} + + // ── 公共 API(IPC 入口;委托给 ./flows 下按任务拆分的各 flow)── + + /** 对指定 PR 跑评审微流程(agent:run)。 */ + runReview(pr: StoredPullRequest): Promise { + return reviewFlow(this, pr); + } + + /** 对指定 PR 跑自由规划 Agent(agent:ask)。 */ + runPlanning( + pr: StoredPullRequest, + question: string, + referencedContext?: string, + ): Promise { + return planningFlow(this, pr, question, referencedContext); + } + + /** + * 运行期间追加用户消息(agent:enqueueMessage):有 Agent 在跑 → 入队,下一主 Agent 周期 drain 注入 + * (queued=true);无在跑 → 直接起一轮自由规划兜底(queued=false,fire-and-forget,不丢消息)。 + */ + enqueueMessage(pr: StoredPullRequest, message: string): { queued: boolean } { + const text = message.trim(); + if (!text) return { queued: false }; + if (this.agentControllers.has(pr.localId)) { + const q = this.pendingInputByPr.get(pr.localId) ?? []; + q.push(text); + this.pendingInputByPr.set(pr.localId, q); + this.ctx.logger.info( + { prLocalId: pr.localId, queueLen: q.length }, + 'agent message queued (mid-run)', + ); + return { queued: true }; + } + // 竞态兜底:检查到没有在跑 → 直接起一轮自由规划(UI 经 step / conversation 事件更新)。 + void this.runPlanning(pr, text).catch((err: unknown) => { + this.ctx.logger.warn({ err, prLocalId: pr.localId }, 'enqueueMessage fallback planning failed'); + }); + return { queued: false }; + } + + /** 暂停某 PR 的 Agent 运行(agent:stop):abort 其 AbortController。 */ + stop(localId: string): { ok: boolean } { + const ac = this.agentControllers.get(localId); + if (!ac) return { ok: false }; + ac.abort(); + return { ok: true }; + } + + /** + * poll tick:满足开关 + 未在跑 + bridge 就绪时跑一遍 AutoPilot pass(准入门控 + 台账去重在 autopilotPass + * 内)。busy 锁防止上一遍未完又叠跑。见 docs/arch/06-agent.md「AutoPilot」。 + */ + runAutopilotIfDue(): void { + const ap = this.ctx.bootstrap.config.agent.autopilot; + if (!ap.enabled || this.autopilotBusy || !this.ctx.getPrAgentBridge()) return; + // 准入通过 → fire-and-forget 异步 pass(poll tick 不阻塞);busy 锁在 autopilotPass 内成对管理。 + void autopilotPass(this); + } + + /** + * poll tick 后调用:把已被移除 / purge(不再在 listStoredPullRequests 里)的 PR 上仍在执行的 + * agent 操作一律直接终止——PR 都没了,继续评审无意义且浪费 LLM / 占用 worktree。 + */ + async terminateAgentsForGonePrs(): Promise { + const { stateStore, logger } = this.ctx; + const opPrIds = new Set(); + for (const id of this.agentControllers.keys()) opPrIds.add(id); + for (const id of this.runQueue.queuedPrLocalIds()) opPrIds.add(id); + if (opPrIds.size === 0) return; + const live = new Set((await listStoredPullRequests(stateStore)).map((p) => p.localId)); + for (const id of opPrIds) { + if (!live.has(id)) { + logger.info({ prLocalId: id }, 'agent ops terminated: pr removed/purged'); + this.terminateAgentForPr(id); + } + } + } + + // ── OrchestratorRuntime 实现(供 ./flows 复用的状态访问 + 共享 helper)── + + registerController(localId: string, ac: AbortController): void { + this.agentControllers.set(localId, ac); + } + + clearController(localId: string): void { + this.agentControllers.delete(localId); + } + + markRunning(localId: string): void { + this.runningAgentPrs.add(localId); + this.broadcastAgentRunning(); + } + + unmarkRunning(localId: string): void { + if (this.runningAgentPrs.delete(localId)) this.broadcastAgentRunning(); + } + + setAutopilotBusy(busy: boolean): void { + this.autopilotBusy = busy; + } + + /** 取出并清空某 PR 的待处理用户消息队列。 */ + takePending(localId: string): string[] { + const q = this.pendingInputByPr.get(localId); + if (!q || q.length === 0) return []; + this.pendingInputByPr.delete(localId); + return q; + } + + /** + * 每个编排步骤的统一出口:① 后台日志(kind / tool / 用时,便于排障与离线回看;thought 与 result 不入 + * 日志避免刷屏 + 泄漏,完整步骤已落 transcript.json);② 广播给渲染层(agent:stepProgress)做过程化展示。 + */ + emitStep(pr: StoredPullRequest, sessionId: string, step: AgentStep): void { + this.ctx.logger.info( + { + prLocalId: pr.localId, + sessionId, + kind: step.kind, + tool: step.toolCall?.tool, + thinkMs: step.thinkMs, + }, + 'agent step', + ); + this.ctx.broadcast('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); + } + + /** 设置 LLM env + 临时 chat cwd + chat 函数,运行 fn,收尾清理临时目录。 + * signal:用户停止时 abort → 杀掉在跑的 LLM chat 子进程,让思考阶段也能立即中止(不必等模型返回)。 */ + async withAgentChat(fn: (chat: AgentChat) => Promise, signal?: AbortSignal): Promise { + const { getPrAgentBridge, bootstrap } = this.ctx; + const bridge = getPrAgentBridge(); + if (!bridge) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); + // 复用与 pr-agent run 同一套 LLM env(provider 凭据 / 模型 / 代理 / 响应语言)。代理 env 先铺底(非 + // pr-agent 范畴);LLM 凭据/模型 + 编排 chat 专属档(响应语言 / 低推理档 / 提示缓存)由 buildChatEnv 按 + // 意图组装。低档与缓存仅作用于本 chat spawn:pr-agent 工具 run(/review 等)的 env 不含 → 仍满档推理。 + const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); + const env: Record = { + ...buildProxyEnv(bootstrap.config.proxy), + ...buildChatEnv(activeLlm, { + responseLanguage: getMainLanguage(), + lowReasoning: true, + promptCache: true, + }), + }; + // chat 子进程落到中性临时目录(cli 模式避免吃到被评审仓库的 CLAUDE.md)。 + const chatCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'meebox-agent-chat-')); + try { + const chat: AgentChat = async ({ system, user, maxOutputTokens }) => { + const r = await bridge.chat({ system, user, maxOutputTokens, env, cwd: chatCwd, signal }); + const acc = newUsageAcc(); + for (const line of (r.stderr ?? '').split('\n')) accumulateUsageSentinel(line, acc); + return { text: r.stdout.trim(), usage: finalizeUsage(acc) }; + }; + return await fn(chat); + } finally { + await fs.rm(chatCwd, { recursive: true, force: true }); + } + } + + /** + * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— + * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 + * updatedAt,给 PR 列表建议徽标 + AutoPilot 同版本去重)。失败 / 用户停止(paused)不落,便于重试。 + */ + async recordReviewSummaryMessage(pr: StoredPullRequest, session: AgentSession): Promise { + if (session.status !== 'done' || !session.summary) return; + await appendAgentMessage(this.ctx.stateStore, pr.localId, { + role: 'assistant', + content: session.summary, + recommendation: session.recommendation, + }); + await writeAutopilotLedger(this.ctx.stateStore, { + prLocalId: pr.localId, + autoReviewedUpdatedAt: pr.updatedAt, + decision: 'review', + recommendation: session.recommendation?.verdict, + at: new Date().toISOString(), + }); + // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 + this.ctx.broadcast('agent:conversationChanged', { prLocalId: pr.localId }); + } + + // ── 私有 helper ── + + private broadcastAgentRunning(): void { + this.ctx.broadcast('agent:runningChanged', { prLocalIds: [...this.runningAgentPrs] }); + } + + /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ + private terminateAgentForPr(localId: string): void { + this.agentControllers.get(localId)?.abort(); + this.runQueue.cancelRunsForPr(localId); + } +} diff --git a/apps/desktop/src/main/agent-planning.ts b/apps/desktop/src/main/services/agent/planning.ts similarity index 75% rename from apps/desktop/src/main/agent-planning.ts rename to apps/desktop/src/main/services/agent/planning.ts index 8e003d5a..da24ee9d 100644 --- a/apps/desktop/src/main/agent-planning.ts +++ b/apps/desktop/src/main/services/agent/planning.ts @@ -12,16 +12,19 @@ import { updateAgentSession, writeAgentConversation, } from '@meebox/poller'; -import type { AgentMessage } from '@meebox/shared'; +import { READ_RUN_TOOL_IDS, type AgentMessage } from '@meebox/shared'; import type { Rule } from '@meebox/rules'; import type { AgentSession, AgentStep, + AgentTodoItem, ReviewRun, + ReviewRunTool, StoredPullRequest, ToolCatalogEntry, } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; +import { buildStepLabels, buildSummarySections, mapTerminationReason } from './labels.js'; /** * 把自由规划编排器(runPlanningAgent)接到主进程:自然语言入口的「对话即委派」。 @@ -34,8 +37,6 @@ function reviewRunText(run: ReviewRun): string { return (run.stdout ?? '').split(STDOUT_LOG_SEP)[0]?.trim() ?? ''; } -const READ_TOOLS = new Set(['describe', 'review', 'ask']); - // 会话压缩:存储超阈值时把较早消息摘要成一条 digest、仅留最近若干条原文,控制存储与后续注入规模 // (约定会话上下文不超 LLM 半窗:先压缩/裁剪再注入)。阈值高于注入预算,超出才触发、不频繁。 const CONVO_COMPACT_THRESHOLD_CHARS = 80000; @@ -47,8 +48,8 @@ const COMPACT_SYSTEM = /** 存储超阈值时,把较早消息摘要为一条 digest 替换之;未超阈值 / 失败则原样保留。 */ async function maybeCompactConversation( - stateStore: AgentPlanningDeps['stateStore'], - chat: AgentPlanningDeps['chat'], + stateStore: PlanningDeps['stateStore'], + chat: PlanningDeps['chat'], prLocalId: string, now: () => Date, ): Promise { @@ -75,34 +76,44 @@ async function maybeCompactConversation( } } -export interface AgentPlanningDeps { +export interface PlanningDeps { stateStore: StateStore; - enqueueRun: ( - pr: StoredPullRequest, - tool: 'describe' | 'review' | 'ask', - question?: string, - ) => Promise; + enqueueRun: (pr: StoredPullRequest, tool: ReviewRunTool, question?: string) => Promise; chat: (input: { system: string; user: string }) => Promise; agentContext: AgentContext; toolCatalog: ToolCatalogEntry[]; matchedRule?: Rule | null; language: string; maxSteps: number; + /** 用户选中的代码引用(隐式上下文):注入规划 LLM 当轮提示,不进持久化用户消息。 */ + referencedContext?: string; signal?: AbortSignal; onStep?: (sessionId: string, step: AgentStep) => void; /** 持久化 Agent 主动记下的非隐私条目到各可写上下文文件(USER/MEMORY/AGENTS)。 */ recordMemory?: (notes: AgentMemoryNotes) => Promise; + /** + * 取出运行期间排队的用户新消息(中途输入转向):每轮顶部由 planner 调用。实现方(orchestrator) + * 负责持久化进会话并广播刷新;此处直接透传给 planner,由其并入当轮 progress。 + */ + drainPendingInput?: () => Promise | string[]; + /** 计划(todo)更新回调:planner 给出 / 更新 plan 时调用,由 orchestrator 持久化 + 广播。 */ + recordPlan?: (todo: AgentTodoItem[]) => void | Promise; } -export async function runAgentPlanning( +export async function runPlanning( pr: StoredPullRequest, userRequest: string, - deps: AgentPlanningDeps, + deps: PlanningDeps, now: () => Date = () => new Date(), ): Promise { // 多轮对话:先读既往消息(注入规划上下文),再把本轮用户输入追加为一条消息(持久化)。 const history = await getAgentConversation(deps.stateStore, pr.localId); - await appendAgentMessage(deps.stateStore, pr.localId, { role: 'user', content: userRequest }, now); + await appendAgentMessage( + deps.stateStore, + pr.localId, + { role: 'user', content: userRequest }, + now, + ); const session = await startAgentSession( deps.stateStore, @@ -116,8 +127,8 @@ export async function runAgentPlanning( chat: deps.chat, runTool: async ({ tool, question }) => { const bare = tool.replace(/^\//, ''); - if (!READ_TOOLS.has(bare)) throw new Error(`不支持的工具:${tool}`); - const run = await deps.enqueueRun(pr, bare as 'describe' | 'review' | 'ask', question); + if (!READ_RUN_TOOL_IDS.has(bare)) throw new Error(`不支持的工具:${tool}`); + const run = await deps.enqueueRun(pr, bare as ReviewRunTool, question); if (run.status !== 'succeeded') { throw new Error(`pr-agent ${bare} 未成功:${run.errorMessage ?? run.status}`); } @@ -128,6 +139,8 @@ export async function runAgentPlanning( deps.onStep?.(session.id, step); }, signal: deps.signal, + drainPendingInput: deps.drainPendingInput, + recordPlan: deps.recordPlan, }, { context: deps.agentContext, @@ -135,14 +148,17 @@ export async function runAgentPlanning( toolCatalog: deps.toolCatalog, matchedRule: deps.matchedRule, language: deps.language, + labels: buildStepLabels(), + summarySections: buildSummarySections(), userRequest, history, + referencedContext: deps.referencedContext, maxSteps: deps.maxSteps, }, ); // 把 Agent 收尾回答追加为一条助手消息(评审类带 recommendation);暂停 / 空回答不记。 - if (result.finalText && result.terminationReason !== '用户暂停') { + if (result.finalText && result.terminationReason !== 'aborted') { await appendAgentMessage( deps.stateStore, pr.localId, @@ -162,21 +178,25 @@ export async function runAgentPlanning( return ( (await updateAgentSession(deps.stateStore, pr.localId, { - status: result.terminationReason === '用户暂停' ? 'paused' : 'done', + status: result.terminationReason === 'aborted' ? 'paused' : 'done', summary: result.finalText, recommendation: result.recommendation, finishedAt: now().toISOString(), - terminationReason: result.terminationReason, + terminationReason: mapTerminationReason(result.terminationReason), })) ?? session ); } catch (err) { // 用户停止(abort 杀掉在跑的 chat / 工具子进程 → 抛错)→ 干净的 paused 收尾,不当失败报错。 - const aborted = deps.signal?.aborted || (err instanceof Error && err.message === '用户暂停'); + const aborted = deps.signal?.aborted || (err instanceof Error && err.message === 'aborted'); return ( (await updateAgentSession(deps.stateStore, pr.localId, { status: aborted ? 'paused' : 'failed', finishedAt: now().toISOString(), - terminationReason: aborted ? '用户暂停' : err instanceof Error ? err.message : String(err), + terminationReason: aborted + ? mapTerminationReason('aborted') + : err instanceof Error + ? err.message + : String(err), })) ?? session ); } diff --git a/apps/desktop/src/main/agent-review.ts b/apps/desktop/src/main/services/agent/review.ts similarity index 69% rename from apps/desktop/src/main/agent-review.ts rename to apps/desktop/src/main/services/agent/review.ts index e4b27b23..8f3ddb07 100644 --- a/apps/desktop/src/main/agent-review.ts +++ b/apps/desktop/src/main/services/agent/review.ts @@ -1,15 +1,18 @@ -import { runReviewMicroflow, type AgentContext } from '@meebox/agent'; +import { runReviewMicroflow, type AgentContext, type ReviewPlan } from '@meebox/agent'; import { appendAgentStep, startAgentSession, updateAgentSession } from '@meebox/poller'; import type { Rule } from '@meebox/rules'; import type { AgentSession, AgentStep, + AskVerdict, ReviewRun, + ReviewRunTool, StoredPullRequest, TokenUsage, ToolCatalogEntry, } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; +import { buildStepLabels, buildSummarySections, mapTerminationReason } from './labels.js'; /** * 把纯逻辑的 `runReviewMicroflow` 接到主进程能力上(见 docs/arch/06-agent.md @@ -26,14 +29,21 @@ function reviewRunText(run: ReviewRun): string { return (run.stdout ?? '').split(STDOUT_LOG_SEP)[0]?.trim() ?? ''; } -export interface AgentReviewDeps { +export interface ReviewDeps { stateStore: StateStore; - /** 入队一个 pr-agent run,resolve 完成的 ReviewRun(与用户手动 run 共用队列)。 */ + /** 入队一个 pr-agent run,resolve 完成的 ReviewRun(与用户手动 run 共用队列)。复评 /ask 携引用上下文 + 前向链。 */ enqueueRun: ( pr: StoredPullRequest, - tool: 'describe' | 'review' | 'ask', + tool: ReviewRunTool, question?: string, + referencedContext?: string, + referencedFinding?: ReviewRun['referencedFinding'], ) => Promise; + /** PR3:复评裁决 replace/drop → 关闭被取代的原 review finding(写 FindingClosure + 广播)。缺省 = 不关。 */ + closeFinding?: ( + pr: StoredPullRequest, + call: { runId: string; findingId: string; byAskRunId: string; verdict: AskVerdict }, + ) => Promise; /** 经独立 LLM 通道做一次受限对话(判严重性 / 出总结)。 */ chat: (input: { system: string; user: string }) => Promise<{ text: string; usage?: TokenUsage }>; agentContext: AgentContext; @@ -43,6 +53,8 @@ export interface AgentReviewDeps { toolCatalog?: ToolCatalogEntry[]; maxFollowupAsks: number; summaryMaxChars: number; + /** 评审执行计划(步骤序列);省略 / 非法时微流程回落默认全集。仅 AutoPilot 按规则注入,手动评审省略。 */ + plan?: ReviewPlan; /** 步骤流式回调(广播给渲染层)。 */ onStep?: (sessionId: string, step: AgentStep) => void; /** 用户停止:透传给微流程,思考 / 执行任意阶段都能立即中止(停止按钮 → agent:stop)。 */ @@ -55,9 +67,9 @@ export interface AgentReviewDeps { * 对一个 PR 跑评审微流程并落盘会话。返回收尾后的 AgentSession(成功 done / 失败 failed)。 * 微流程内部工具失败会抛错,这里兜成 failed 会话而非向上抛(背景自动化不该崩主流程)。 */ -export async function runAgentReview( +export async function runReview( pr: StoredPullRequest, - deps: AgentReviewDeps, + deps: ReviewDeps, now: () => Date = () => new Date(), ): Promise { // 步数上限按微流程模板推导:describe + review + ≤N 追问 + 总结(+判定余量)。 @@ -72,13 +84,27 @@ export async function runAgentReview( try { const result = await runReviewMicroflow( { - runTool: async ({ tool, question }) => { - const run = await deps.enqueueRun(pr, tool, question); + runTool: async ({ tool, question, referencedContext, referencedFinding }) => { + const run = await deps.enqueueRun( + pr, + tool, + question, + referencedContext, + referencedFinding, + ); if (run.status !== 'succeeded') { throw new Error(`pr-agent ${tool} 未成功:${run.errorMessage ?? run.status}`); } - return { text: reviewRunText(run), usage: run.tokenUsage }; + // 回带 runId / findings / askVerdict:供 judge 按 id 点名 finding、asks 复评关联与自动关闭。 + return { + text: reviewRunText(run), + usage: run.tokenUsage, + runId: run.id, + findings: run.findings, + askVerdict: run.askVerdict, + }; }, + closeFinding: deps.closeFinding ? (call) => deps.closeFinding!(pr, call) : undefined, chat: deps.chat, onStep: async (step) => { const tagged = deps.autopilot && firstStep ? { ...step, autopilot: true } : step; @@ -93,7 +119,10 @@ export async function runAgentReview( pr: { title: pr.title, description: pr.description, targetBranch: pr.targetRef.displayId }, matchedRule: deps.matchedRule, language: deps.language, + labels: buildStepLabels(), + summarySections: buildSummarySections(), toolCatalog: deps.toolCatalog, + plan: deps.plan, maxFollowupAsks: deps.maxFollowupAsks, summaryMaxChars: deps.summaryMaxChars, }, @@ -109,12 +138,16 @@ export async function runAgentReview( ); } catch (err) { // 用户停止(abort)→ 干净的 paused 收尾,不当失败报错;其余异常仍记为 failed。 - const aborted = deps.signal?.aborted || (err instanceof Error && err.message === '用户暂停'); + const aborted = deps.signal?.aborted || (err instanceof Error && err.message === 'aborted'); return ( (await updateAgentSession(deps.stateStore, pr.localId, { status: aborted ? 'paused' : 'failed', finishedAt: now().toISOString(), - terminationReason: aborted ? '用户暂停' : err instanceof Error ? err.message : String(err), + terminationReason: aborted + ? mapTerminationReason('aborted') + : err instanceof Error + ? err.message + : String(err), })) ?? session ); } diff --git a/apps/desktop/src/main/services/agent/runtime.ts b/apps/desktop/src/main/services/agent/runtime.ts new file mode 100644 index 00000000..44fbefec --- /dev/null +++ b/apps/desktop/src/main/services/agent/runtime.ts @@ -0,0 +1,39 @@ +import type { AgentSession, AgentStep, StoredPullRequest, TokenUsage } from '@meebox/shared'; +import type { ServiceContext } from '../context.js'; +import type { RunQueue } from '../pr-agent/index.js'; + +/** 共享 chat 通道:system + user → 文本 + usage。agent:run 评审与 AutoPilot 都用。 */ +export type AgentChat = (input: { + system: string; + user: string; + /** 输出 token 上限(轻量路由判读封顶用,见 ChatRunOptions.maxOutputTokens)。 */ + maxOutputTokens?: number; +}) => Promise<{ text: string; usage?: TokenUsage }>; + +/** + * 编排运行时:有状态协调器(Orchestrator)暴露给各 flow(review / planning / autopilot)的状态访问 + + * 共享 helper 面。flow 以自由函数形式按「一任务一文件」拆分,经此 runtime 复用协调器的运行态与公共能力, + * 而不各自持有可变状态。Orchestrator 实现本接口、把 `this` 作为 runtime 传入各 flow。 + */ +export interface OrchestratorRuntime { + readonly ctx: ServiceContext; + readonly runQueue: RunQueue; + /** 注册某 PR 的 AbortController(停止按钮 agent:stop 用)。 */ + registerController(localId: string, ac: AbortController): void; + /** 清除某 PR 的 AbortController(收尾)。 */ + clearController(localId: string): void; + /** 标记某 PR「执行中」并广播(纯思考阶段也显示)。 */ + markRunning(localId: string): void; + /** 取消某 PR「执行中」标记并广播。 */ + unmarkRunning(localId: string): void; + /** 步骤统一出口:后台日志 + agent:stepProgress 广播。 */ + emitStep(pr: StoredPullRequest, sessionId: string, step: AgentStep): void; + /** 取出并清空某 PR 的待处理用户消息(中途输入转向)。 */ + takePending(localId: string): string[]; + /** 设置 LLM env + 临时 chat cwd + chat 函数后运行 fn,收尾清理临时目录。 */ + withAgentChat(fn: (chat: AgentChat) => Promise, signal?: AbortSignal): Promise; + /** 评审收尾统一落地:成功且有总结时追加 assistant 总结消息 + 写台账 + 广播会话变更。 */ + recordReviewSummaryMessage(pr: StoredPullRequest, session: AgentSession): Promise; + /** AutoPilot 单并发 busy 锁置位 / 复位。 */ + setAutopilotBusy(busy: boolean): void; +} diff --git a/apps/desktop/src/main/services/app.ts b/apps/desktop/src/main/services/app.ts new file mode 100644 index 00000000..ade83107 --- /dev/null +++ b/apps/desktop/src/main/services/app.ts @@ -0,0 +1,36 @@ +import { app } from 'electron'; +import type { BootstrapResult } from '@meebox/config'; +import type { ConnectionSummary } from '@meebox/ipc'; +import type { AppInfo } from '@meebox/shared'; +import type { BuiltAdapter } from '../adapters.js'; + +/** 应用 / 运行时版本信息(app:info)。纯数据组装,不依赖 controller 上下文。 */ +export function buildAppInfo(bootstrap: BootstrapResult): AppInfo { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + nodeVersion: process.versions.node, + platform: process.platform, + firstRun: bootstrap.firstRun, + }; +} + +/** 当前活动连接的状态摘要(app:connections)。 */ +export function buildConnectionSummaries( + bootstrap: BootstrapResult, + adapters: readonly BuiltAdapter[], +): ConnectionSummary[] { + // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 + const activeId = bootstrap.config.active_connection_id; + return adapters + .filter(({ connectionId }) => connectionId === activeId) + .map(({ connectionId, adapter }) => { + const conn = bootstrap.config.connections.find((c) => c.id === connectionId); + return { + connectionId, + displayName: conn?.display_name ?? connectionId, + user: adapter.getCurrentUser(), + capabilities: adapter.capabilities(), + }; + }); +} diff --git a/apps/desktop/src/main/services/broadcast.ts b/apps/desktop/src/main/services/broadcast.ts new file mode 100644 index 00000000..7e74b809 --- /dev/null +++ b/apps/desktop/src/main/services/broadcast.ts @@ -0,0 +1,13 @@ +import { BrowserWindow } from 'electron'; +import type { IpcEvents } from '@meebox/ipc'; + +/** + * 向所有窗口广播一条 main → renderer 推送事件。收口原先散落各处的 + * `for (const win of BrowserWindow.getAllWindows()) win.webContents.send(...)`, + * 并按 IpcEvents 强类型约束 event ↔ payload。 + */ +export function broadcast(event: E, payload: IpcEvents[E]): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(event, payload); + } +} diff --git a/apps/desktop/src/main/services/comments.ts b/apps/desktop/src/main/services/comments.ts new file mode 100644 index 00000000..d42164ba --- /dev/null +++ b/apps/desktop/src/main/services/comments.ts @@ -0,0 +1,45 @@ +import type { PlatformAdapter, PrComment } from '@meebox/shared'; + +/** + * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。不依赖 controller 上下文。 + * + * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version + * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) + * - canEdit: author.name === 当前 PAT 用户 && 有 version + * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) + * + * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 + * 自己比对 author / version / replies,链路最短最稳。 + */ +export function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { + const me = adapter.getCurrentUser(); + if (!me) { + return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); + } + // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 + // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 + const noDeleteWithReplies = adapter.capabilities().commentOptimisticLock; + return setOwnershipRecursive(comments, (c) => { + const isMine = c.author.name === me.name; + const hasVersion = typeof c.version === 'number'; + return { + canDelete: isMine && hasVersion && (!noDeleteWithReplies || c.replies.length === 0), + canEdit: isMine && hasVersion, + }; + }); +} + +function setOwnershipRecursive( + comments: PrComment[], + judge: (c: PrComment) => { canDelete: boolean; canEdit: boolean }, +): PrComment[] { + return comments.map((c) => { + const flags = judge(c); + return { + ...c, + canDelete: flags.canDelete, + canEdit: flags.canEdit, + replies: setOwnershipRecursive(c.replies, judge), + }; + }); +} diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts new file mode 100644 index 00000000..1fd30532 --- /dev/null +++ b/apps/desktop/src/main/services/context.ts @@ -0,0 +1,87 @@ +import type { Logger } from 'pino'; +import type { BootstrapResult } from '@meebox/config'; +import type { PrAgentBridge } from '@meebox/pr-agent-bridge'; +import type { Poller } from '@meebox/poller'; +import type { RepoMirrorManager } from '@meebox/repo-mirror'; +import type { PrAgentStatus } from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { ConnectionRuntime } from '../adapters.js'; +import type { Orchestrator } from './agent/index.js'; +import { broadcast } from './broadcast.js'; +import { PrService } from './pr-service.js'; +import type { RunQueue } from './pr-agent/index.js'; + +/** registerIpcHandlers 的外部依赖(由 main/index.ts 注入)。 */ +export interface RegisterDeps { + bootstrap: BootstrapResult; + logger: Logger; + /** 惰性取 pr-agent 探测状态:探测异步进行(不阻塞建窗),await 拿最终结果 */ + getPrAgentStatus: () => Promise; + /** 惰性取 bridge 实例;探测未完成 / 不可用 (embedded / CLI 都没有) 时为 null */ + getPrAgentBridge: () => PrAgentBridge | null; + /** 嵌入式运行时解释器路径(embedded 策略下执行期补 .secrets.toml 用),非 embedded 可空 */ + embeddedPythonPath?: string; + stateStore: JsonFileStateStore; + poller: Poller; + /** 可变连接运行时(全量 adapters + adapterByHost);设置页改连接后被 reconfigure 原地替换 */ + connectionRuntime: ConnectionRuntime; + /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ + reconfigureConnections: () => Promise; + repoMirror: RepoMirrorManager; +} + +/** + * 各 service 共享的运行时上下文:外部依赖 + 跨域工具(广播 / Agent 目录)+ PR 领域服务。 + * 跨域服务(run 队列 / Agent 编排)在此之上由 ipc.ts 合成 ControllerContext,避免构造环。 + */ +export interface ServiceContext extends RegisterDeps { + /** 向所有窗口广播 main → renderer 事件(按 IpcEvents 强类型)。 */ + broadcast: typeof broadcast; + /** 生效的 Agent 目录:用户配置优先,未配置则回落默认位置(~/.code-meeseeks/agent)。 */ + effectiveAgentDir(): string; + /** PR 领域服务:PR 定位 / adapter / 镜像 / diff base / 评论缓存。 */ + pr: PrService; +} + +/** + * controller 层统一上下文:在 ServiceContext 之上再挂两个跨域 service(run 队列 / Agent 编排), + * 使所有 controller 共享同一 `ctx` 入参即可拿到全部能力,签名统一为 `(ctx, req, evt)`。 + * 两个跨域服务以基础 ServiceContext 构建(见 ipc.ts 装配顺序),构建完成后合成本上下文。 + */ +export interface ControllerContext extends ServiceContext { + runQueue: RunQueue; + orchestrator: Orchestrator; +} + +export function createServiceContext(deps: RegisterDeps): ServiceContext { + return { + ...deps, + broadcast, + effectiveAgentDir: () => deps.bootstrap.config.agent.dir || deps.bootstrap.paths.agentDir, + pr: new PrService({ + bootstrap: deps.bootstrap, + stateStore: deps.stateStore, + connectionRuntime: deps.connectionRuntime, + repoMirror: deps.repoMirror, + }), + }; +} + +// === controller 层进程级单例上下文 === +// registerIpcHandlers 启动时合成一次 ControllerContext(base + runQueue + orchestrator)并安装; +// controller 经 getContext() 取用,从而 handler 签名回归标准 ipcMain.handle 形态 (req, evt)、不带 ctx。 +// 单一真相、随进程生命周期存活;测试可先 setControllerContext(mock) 再调 controller。 +let currentContext: ControllerContext | undefined; + +/** 由 registerIpcHandlers 在装配完成后调用,安装进程级 controller 上下文单例。 */ +export function setControllerContext(ctx: ControllerContext): void { + currentContext = ctx; +} + +/** 取 controller 上下文单例;未初始化(registerIpcHandlers 之前 / 模块加载期)即抛错兜住时序。 */ +export function getContext(): ControllerContext { + if (!currentContext) { + throw new Error('ControllerContext 尚未初始化(registerIpcHandlers 未调用)'); + } + return currentContext; +} diff --git a/apps/desktop/src/main/services/pr-agent/index.ts b/apps/desktop/src/main/services/pr-agent/index.ts new file mode 100644 index 00000000..4b4d278f --- /dev/null +++ b/apps/desktop/src/main/services/pr-agent/index.ts @@ -0,0 +1,6 @@ +/** + * pr-agent run 子系统:调度(RunQueue:并发 / 优先级 / 取消)+ 执行(RunExecutor,内部协作件,不外暴露)。 + * 对外只暴露 RunQueue 与队列相关类型。 + */ +export { RunQueue } from './run-queue.js'; +export type { QueueItem, RunPriority } from './run-queue.js'; diff --git a/apps/desktop/src/main/services/pr-agent/run-executor.ts b/apps/desktop/src/main/services/pr-agent/run-executor.ts new file mode 100644 index 00000000..1534d536 --- /dev/null +++ b/apps/desktop/src/main/services/pr-agent/run-executor.ts @@ -0,0 +1,495 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { loadAgentRules } from '@meebox/agent'; +import { + PRAGENT_LOCAL_OUTPUT, + PrAgentRunError, + askLanguageSuffixFor, + buildExtraInstructions, + buildToolEnv, + extraInstructionsEnvKey, + stripAskQuestionEcho, + type PrAgentBridge, +} from '@meebox/pr-agent-bridge'; +import { + addFindingClosure, + dropPendingFindingDrafts, + finishReviewRun, + parseReviewOutput, + startReviewRun, +} from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import { + AppError, + ERROR_CODES, + type ReviewRun, + type ReviewRunStatus, +} from '@meebox/shared'; +import { getMainLanguage } from '../../i18n/index.js'; +import { resolveActiveLlmProfile } from '../../utils/agent.js'; +import { buildPrContext } from '../../utils/pr-context.js'; +import { buildProxyEnv } from '../../utils/proxy.js'; +import type { ServiceContext } from '../context.js'; +import type { QueueItem } from './run-queue.js'; +import { + accumulateUsageSentinel, + finalizeUsage, + newUsageAcc, + stripUsageSentinels, +} from './usage.js'; +import { neutralizeWorktreeInstructions } from './worktree-sanitize.js'; + +/** finishReviewRun 的收尾 patch 类型(收尾 helper 的返回)。 */ +type FinishPatch = Parameters[3]; + +/** + * pr-agent run 的**执行器**(与队列调度 RunQueue 分离):给定一个已 dequeue 的队列项,跑完一个 run。 + * 调度(并发 / 优先级 / 取消 / 泵)归 RunQueue;本类只管「怎么跑一个 run」,无队列状态。 + * + * execute 编排五个阶段:startRun(落盘 + 标记开始)→ prepareWorkspace(镜像 + worktree)→ + * buildInvocation(env + 提示词组装)→ bridge.run(spawn)→ collectOutput(读产物 + 解析)→ 收尾落盘。 + */ +export class RunExecutor { + private readonly execFileP = promisify(execFile); + /** embedded .secrets.toml 兜底的 memo(只在首个 embedded run 解析一次目录 + 写文件)。 */ + private embeddedSecretsEnsured: Promise | null = null; + + constructor(private readonly ctx: ServiceContext) {} + + /** + * 真正执行一个 queue item:startRun → worktree → bridge.run → finishWith。 + * 由 RunQueue.pump() 调用;任何抛错都被调度层兜成 Promise reject,外层 pragent:run 调用方收到。 + * notifyStarted:startedAt 落定后回调调度层广播队列变化(执行器不持队列态)。 + */ + async execute(item: QueueItem, notifyStarted: () => void): Promise { + const { getPrAgentBridge, embeddedPythonPath, stateStore, broadcast } = this.ctx; + const bridge = getPrAgentBridge(); + if (!bridge) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); + const { req, pr } = item; + + const run = await this.startRun(item, bridge, notifyStarted); + const t0 = Date.now(); + // 真实 token 用量累加器:sitecustomize 的 litellm callback 把每次调用的 usage 以 + // `@@MEEBOX_USAGE@@ {json}` 哨兵行打到 stderr,下面 onLine 拦截累加(无需临时文件 / env)。 + const usageAcc = newUsageAcc(); + const onLine = (line: string, stream: 'stdout' | 'stderr'): void => { + // 拦截 usage 哨兵行:累加后不转发给 renderer(避免污染实时日志)。 + if (stream === 'stderr' && accumulateUsageSentinel(line, usageAcc)) return; + broadcast('pragent:runProgress', { runId: run.id, line, stream }); + }; + const finishWith = async (patch: Parameters[3]): Promise => { + const updated = await finishReviewRun(stateStore, pr.localId, run.id, patch); + return updated ?? { ...run, ...patch }; + }; + + const wt = await this.prepareWorkspace(pr); + try { + const { env, extraArgs, askLangSuffix } = await this.buildInvocation( + req, + pr, + run.id, + wt.path, + ); + + // CLI 模式 /ask 把子进程 cwd 落到 worktree(取完整文件上下文,buildInvocation 已设 MEEBOX_CLI_WORKDIR)。 + // 落 cwd 前先清空仓库自带的 agent 指令文件,避免被 CLI 自动加载污染回答。env key 在 = 走此路径。 + if (env['MEEBOX_CLI_WORKDIR']) { + await neutralizeWorktreeInstructions(env['MEEBOX_CLI_WORKDIR'], this.ctx.logger); + } + + // embedded 策略:执行期在嵌入式安装目录补空 .secrets.toml 压掉启动告警(memo 化只首次做)。 + // local-cli 不需要(pipx 装的 pr-agent 路径不同,告警也不出)。 + if (bridge.strategy === 'embedded' && embeddedPythonPath) { + await this.ensureEmbeddedSecrets(embeddedPythonPath); + } + + const result = await bridge.run({ + prUrl: pr.url, + tool: req.tool, + env, + onLine, + cwd: wt.path, + targetBranch: wt.targetBranchName, + extraArgs, + signal: item.ac!.signal, + }); + // 真实 token 用量(onLine 累加的 stderr 哨兵行),落到 succeeded / llm-failed 收尾。 + const tokenUsage = finalizeUsage(usageAcc); + const { parsed, fileContent } = await this.collectOutput( + wt, + result.stdout, + req, + run.id, + askLangSuffix, + ); + return await finishWith( + this.finishPatchForResult(result, parsed, fileContent, tokenUsage, t0, run.id), + ); + } catch (err) { + const tokenUsage = finalizeUsage(usageAcc); + const finished = await finishWith( + this.finishPatchForError(err, tokenUsage, t0, run.id), + ); + // 非预期异常(非 PrAgentRunError):落 failed 后仍把异常往上抛,避免吞掉。 + if (!(err instanceof PrAgentRunError)) throw err; + return finished; + } finally { + await wt.cleanup(); + } + } + + /** + * 成功路径收尾 patch:parsed.llmFailure → failed(reason=llm-error),否则 succeeded。 + * pr-agent CLI 可能 exit 0 但 stdout 其实是 LLM 调用全失败(litellm AuthenticationError / + * "Failed to generate prediction with any model" 等 marker)→ 不算 succeeded,UI 用红色失败 chip 渲染。 + * stdout 持久化「LLM 真实产出」(文件内容);原 stdout 留作日志在折叠区供排障。 + */ + private finishPatchForResult( + result: { exitCode: number; stdout: string; stderr: string }, + parsed: ReturnType, + fileContent: string, + tokenUsage: ReturnType, + t0: number, + runId: string, + ): FinishPatch { + const stdout = fileContent + ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` + : result.stdout; + const base = { + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + exitCode: result.exitCode, + stdout, + stderr: stripUsageSentinels(result.stderr), + tokenUsage, + }; + if (parsed.llmFailure) { + this.ctx.logger.warn( + { runId, reason: parsed.llmFailure.message }, + 'pragent exit 0 but LLM call failed; marking run as failed', + ); + // 失败任务不做结构化采集——findings 置空,UI 只展示原始输出(不转 chatpane finding 卡)。 + return { + ...base, + status: 'failed', + errorReason: 'llm-error', + errorMessage: parsed.llmFailure.message, + findings: [], + }; + } + return { + ...base, + status: 'succeeded', + findings: parsed.findings, + summary: parsed.summary, + // 复评裁决(解析自复评 /ask 的 );非复评 / 未给则 undefined。 + askVerdict: parsed.askVerdict, + }; + } + + /** + * 异常路径收尾 patch:PrAgentRunError → cancelled(用户取消)/ failed(其它 reason),尽量解析已收集的 + * 部分 stdout + 记已产生的 token 用量;其它非预期异常 → failed(仅 errorMessage,避免 run 卡在 running)。 + */ + private finishPatchForError( + err: unknown, + tokenUsage: ReturnType, + t0: number, + runId: string, + ): FinishPatch { + if (err instanceof PrAgentRunError) { + // 用户主动取消 → cancelled,其它 reason → failed;二者都落盘让 UI 能从历史 run 里看到该事件。 + const status: ReviewRunStatus = err.reason === 'cancelled' ? 'cancelled' : 'failed'; + this.ctx.logger.warn( + { runId, reason: err.reason, exitCode: err.result.exitCode }, + `pragent run ${status}`, + ); + // 失败 / 取消的任务不做结构化采集——只保留原始输出(stdout/stderr)供展示,不解析成 finding 卡。 + return { + status, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + exitCode: err.result.exitCode, + errorReason: err.reason, + errorMessage: err.message, + stdout: err.result.stdout, + stderr: stripUsageSentinels(err.result.stderr), + findings: [], + tokenUsage, + }; + } + return { + status: 'failed', + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + errorMessage: err instanceof Error ? err.message : String(err), + }; + } + + /** 阶段①:落盘 startReviewRun(用入队预分配 runId)+ 标记 startedAt 并通知调度层广播 + 记日志。 */ + private async startRun( + item: QueueItem, + bridge: PrAgentBridge, + notifyStarted: () => void, + ): Promise { + const { bootstrap, logger, stateStore } = this.ctx; + const { req, pr } = item; + // 提前 resolve active LLM profile — model 字段要随 startReviewRun 一起落盘,让 UI 在 meta 行展示 + // "这次 run 用的什么模型"(持久化用 profile.model 原文,不做 normalizeModel 前缀处理,跟 Settings 一致)。 + const activeLlmForRecord = resolveActiveLlmProfile(bootstrap.config.llm); + // 用入队预分配的 runId 覆盖 startReviewRun 的自生 id,让 cancel(runId) 在 active 状态也能精确定位。 + const run = await startReviewRun(stateStore, { + id: item.info.runId, + prLocalId: pr.localId, + tool: req.tool, + question: req.tool === 'ask' ? req.question : undefined, + prAgentVersion: bridge.version, + strategy: bridge.strategy, + model: activeLlmForRecord?.model || undefined, + // 复评引用前向链:随 run 落盘,UI 据此在 /ask 卡上展示「复评自…」徽标 + 裁决动作。 + referencedFinding: req.tool === 'ask' ? req.referencedFinding : undefined, + }); + // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播(经调度层)。 + item.info = { ...item.info, startedAt: run.startedAt }; + notifyStarted(); + logger.info( + { runId: run.id, localId: pr.localId, tool: req.tool, strategy: bridge.strategy }, + 'pragent run start', + ); + return run; + } + + /** 阶段②:同步镜像 + 按固定 merge-base 物化 worktree(与 UI diff 同源,评审基于 PR 自分叉的改动)。 */ + private async prepareWorkspace(pr: QueueItem['pr']) { + const { repoMirror, pr: prService } = this.ctx; + const repoId = prService.repoIdentityFor(pr); + // 走 ensureMirrorReadyForPr(而非裸 syncMirror):与 UI diff 同源,且复用其自愈——源分支被删 / 强推后 + // 按平台精确 fetch PR 头引用补齐 head sha,否则 materializeWorktree 建 meebox/head 会因对象缺失失败。 + await prService.ensureMirrorReadyForPr(pr); + // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base,而非 targetRef.sha 漂移后混入别的 PR 的两点对比。 + const diffBase = await prService.resolveDiffBaseSha(pr); + return repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); + } + + /** + * 阶段③:组装 bridge.run 的 env + 位置参数。代理 env 铺底 + buildToolEnv(凭据/模型/响应语言/per-tool), + * 再注入 EXTRA_INSTRUCTIONS(PR 上下文 + 命中规则,local provider 不会自己拉,须现读;/ask 跳过)。 + * /ask 把问题作位置参数并在末尾追加目标语言要求(近因位置提升按 UI 语言作答的遵循度)。 + */ + private async buildInvocation( + req: QueueItem['req'], + pr: QueueItem['pr'], + runId: string, + wtPath: string, + ): Promise<{ + env: Record; + extraArgs: string[] | undefined; + askLangSuffix: string; + }> { + const { bootstrap, logger, effectiveAgentDir, pr: prService } = this.ctx; + const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); + // 代理 env 先铺底(非 pr-agent 范畴,仅 HTTP(S)_PROXY 类);LLM 凭据/模型 + 响应语言 + per-tool 配置 + // 由 bridge 的 buildToolEnv 按意图组装——契约 key 收口在 @meebox/pr-agent-bridge。 + const env: Record = { + ...buildProxyEnv(bootstrap.config.proxy), + ...buildToolEnv(activeLlm, { tool: req.tool, responseLanguage: getMainLanguage() }), + }; + + // CLI 模式 /ask:把子进程 cwd 落到(待净化的)worktree,让自由问答能读完整文件(shim cli/install.py + // 据此 env 切 cwd)。describe/review 不下发、维持中性临时目录;API 模式不涉及(远程接口只有 diff)。 + if (req.tool === 'ask' && activeLlm?.provider === 'cli') { + env['MEEBOX_CLI_WORKDIR'] = wtPath; + } + + let prContext = ''; + let matchedRuleInstructions = ''; + let matchedRuleId: string | undefined; + if (req.tool !== 'ask') { + const adapter = prService.adapterFor(pr); + if (adapter) { + try { + prContext = await buildPrContext({ pr, adapter, logger }); + } catch (err) { + logger.warn( + { err, runId, localId: pr.localId }, + 'buildPrContext threw; proceeding without PR context', + ); + } + } + + const rules = await loadAgentRules(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), + }); + const matched = pickMatchingRule(rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: req.tool, + }); + if (matched) { + matchedRuleInstructions = matched.instructions; + matchedRuleId = matched.id; + } + } + + // 提示词组装收口到 @meebox/pr-agent-bridge 的 prompts:语言指示 / anchor marker / 排版 / PR 上下文 / 命中规则。 + const extraInstructions = buildExtraInstructions({ + tool: req.tool, + language: getMainLanguage(), + prContext, + matchedRuleInstructions, + // /ask 选中行引用 + 复评裁决:拼进「问题」(user turn),见下方 askQuestion 组装。 + referencedContext: req.tool === 'ask' ? req.referencedContext : undefined, + // /ask 复评模式:引用了某条 finding 时注入裁决(replace/keep/drop)指示。 + referencedFinding: req.tool === 'ask' ? !!req.referencedFinding : undefined, + }); + // /ask 的 pr_questions prompt **不渲染 extra_instructions**(与 describe/review/improve 不同), + // 经 env 注入对 /ask 是死字段。故 /ask 的指令改为拼进「问题」(user turn,见下方 askQuestion), + // env 注入仅用于其它三个工具。 + if (extraInstructions && req.tool !== 'ask') { + env[extraInstructionsEnvKey(req.tool)] = extraInstructions; + } + if (matchedRuleId) { + logger.info({ runId, ruleId: matchedRuleId, tool: req.tool }, 'pragent run: matched rule'); + } + if (prContext) { + logger.debug( + { runId, tool: req.tool, contextChars: prContext.length }, + 'pragent run: pr context injected', + ); + } + + // ask 工具:问题作为位置参数(user turn,spawn args 单元素,含空格也是一个 arg 不切分),并在问题 + // **末尾**硬性追加语言要求。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量 + // 英文 diff 盖过 → 模型用英文作答;在 user turn 末尾(近因位置、用目标语言书写)再要求一次。en-US 返回空。 + const askLangSuffix = req.tool === 'ask' ? askLanguageSuffixFor(getMainLanguage()) : ''; + let askQuestion: string | undefined; + if (req.tool === 'ask' && req.question) { + // /ask 的指令(结构化分段 / anchor marker / 复评裁决 / 引用上下文)拼进 user turn——pr_questions + // 不读 extra_instructions,唯有问题文本真正到达模型。语言后缀放最末(近因位置最促使按目标语言作答)。 + // 回显(pr-agent 把问题原样写进产物)由 collectOutput 的 stripAskQuestionEcho 整段剥掉。 + const parts = [req.question]; + if (extraInstructions) parts.push(extraInstructions); + if (askLangSuffix) parts.push(askLangSuffix); + askQuestion = parts.join('\n\n'); + } + const extraArgs = askQuestion ? [askQuestion] : undefined; + return { env, extraArgs, askLangSuffix }; + } + + /** + * 阶段⑤:读 local provider 写到 worktree 根的产物文件(落盘文件名见 PRAGENT_LOCAL_OUTPUT),/ask 去掉 + * 回显的问题行,解析为 findings/summary;/review 成功时丢弃旧 pending 草稿(让本轮 finding 成新候选源)。 + * 文件缺失则回退用 stdout 解析。返回解析结果 + 原始文件内容(供收尾拼日志)。 + */ + private async collectOutput( + wt: { path: string }, + resultStdout: string, + req: QueueItem['req'], + runId: string, + askLangSuffix: string, + ): Promise<{ parsed: ReturnType; fileContent: string }> { + const { logger, stateStore, broadcast } = this.ctx; + // cleanup 前必须先把文件读出来(与 buildToolEnv 的 LOCAL__REVIEW_PATH 同源)。 + const outFile = PRAGENT_LOCAL_OUTPUT[req.tool]; + let fileContent = ''; + try { + fileContent = await fs.readFile(path.join(wt.path, outFile), 'utf8'); + } catch (readErr) { + logger.warn( + { err: readErr, wtPath: wt.path, outFile, runId }, + 'pr-agent local provider output file missing; fall back to stdout', + ); + } + // /ask 输出里 pr-agent 把问题原样回显在 answer body 顶部(跟 chat 输入气泡重复);解析前逐字删掉。 + const cleanedContent = + req.tool === 'ask' && req.question?.trim() + ? stripAskQuestionEcho(fileContent, req.question, askLangSuffix) + : fileContent; + const parsed = parseReviewOutput(cleanedContent || resultStdout, req.tool); + // 复评 /ask(引用了某条 finding): + // - 裁决 replace → 把建议提升为带定位的代码评论(取原 finding 的 anchor),渲染 / 采纳同 /review 代码反馈; + // - 裁决 replace / drop → 静默关闭被引用的原 finding(建立关闭关系 + 广播),无需用户手动点关闭。 + // keep / 无裁决:原评论保留、不动。 + if (req.tool === 'ask' && req.referencedFinding && parsed.askVerdict && !parsed.llmFailure) { + const ref = req.referencedFinding; + const anchor = ref.anchor; + if (parsed.askVerdict === 'replace' && anchor && typeof anchor.startLine === 'number') { + // 已自带定位的 code-suggestion(模型按 marker 锚到引用处)保持不动;否则把建议(退到 summary) + // 兜底锚到被引用评论的原位置并升为代码反馈,保证取代评论始终带定位、可采纳。 + const sug = + parsed.findings.find( + (f) => f.sectionKey === 'code-suggestion' || f.sectionKey === 'ask-suggestions', + ) ?? parsed.findings.find((f) => f.sectionKey === 'ask-summary'); + if (sug && !sug.anchor) { + sug.anchor = { ...anchor }; + sug.sectionKey = 'code-feedback'; + sug.category = 'code-feedback'; + } + } + if (parsed.askVerdict === 'replace' || parsed.askVerdict === 'drop') { + try { + await addFindingClosure(stateStore, req.localId, { + runId: ref.runId, + findingId: ref.findingId, + byAskRunId: runId, + verdict: parsed.askVerdict, + }); + broadcast('findingClosures:changed', { localId: req.localId }); + } catch (err) { + logger.warn({ err, runId }, 'auto-close referenced finding on /ask verdict failed'); + } + } + } + // M4 草稿再摄入:/review 成功完成时丢掉 pending+finding 旧草稿(edited/posted/rejected/manual 保留)。 + if (req.tool === 'review') { + try { + const dropped = await dropPendingFindingDrafts(stateStore, req.localId); + if (dropped > 0) { + logger.info( + { runId, localId: req.localId, dropped }, + 'pragent /review: dropped stale pending drafts', + ); + broadcast('drafts:changed', { localId: req.localId }); + } + } catch (err) { + logger.warn({ err, runId }, 'dropPendingFindingDrafts failed'); + } + } + return { parsed, fileContent }; + } + + /** + * embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 .secrets.toml + * (pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥不用 secrets.toml,写个空 + * 文件压掉告警)。memo 化:只在首个 embedded run 解析一次目录 + 写文件,后续直接复用。 + * importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 + */ + private ensureEmbeddedSecrets(pythonPath: string): Promise { + this.embeddedSecretsEnsured ??= (async () => { + const { stdout } = await this.execFileP(pythonPath, [ + '-c', + "import importlib.util,os;print(os.path.dirname(importlib.util.find_spec('pr_agent').origin))", + ]); + const prAgentDir = stdout.trim(); + for (const sub of ['settings', 'settings_prod']) { + const dir = path.join(prAgentDir, sub); + await fs.mkdir(dir, { recursive: true }); + const f = path.join(dir, '.secrets.toml'); + try { + await fs.access(f); + } catch { + await fs.writeFile( + f, + '# meebox placeholder: silence pr-agent warning about a missing .secrets.toml\n', + ); + } + } + })().catch((err: unknown) => { + this.ctx.logger.warn({ err }, 'ensure embedded .secrets.toml failed (ignored)'); + }); + return this.embeddedSecretsEnsured; + } +} diff --git a/apps/desktop/src/main/services/pr-agent/run-queue.ts b/apps/desktop/src/main/services/pr-agent/run-queue.ts new file mode 100644 index 00000000..34980c5f --- /dev/null +++ b/apps/desktop/src/main/services/pr-agent/run-queue.ts @@ -0,0 +1,221 @@ +import type { PragentRunInfo } from '@meebox/ipc'; +import { makeRunId } from '@meebox/poller'; +import { + AppError, + ERROR_CODES, + type ReviewRun, + type ReviewRunTool, + type StoredPullRequest, +} from '@meebox/shared'; +import type { ServiceContext } from '../context.js'; +import { RunExecutor } from './run-executor.js'; + +/** pr-agent run 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ +export type RunPriority = 'user' | 'agent'; + +/** + * 队列项:一次入队的 pr-agent run 的全部上下文(含 resolve/reject 回原始调用方)。归调度器所有;执行器 + * (run-executor)仅以 `import type` 引用本类型,类型在运行时被擦除,故不构成运行时循环依赖。 + */ +export interface QueueItem { + info: PragentRunInfo; + req: { + localId: string; + tool: ReviewRunTool; + question?: string; + referencedContext?: string; + referencedFinding?: ReviewRun['referencedFinding']; + }; + pr: StoredPullRequest; + resolve: (run: ReviewRun) => void; + reject: (err: Error) => void; + /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ + priority: RunPriority; + /** 仅 active 状态填;用于 cancel SIGKILL */ + ac?: AbortController; +} + +/** + * pr-agent run 队列服务。 + * + * FIFO 队列,并发上限 maxConcurrency(post-Docker 下每个 run 独立 worktree + 独立子进程, + * 并发安全)。其余在 waiting 排队;每次 active 完成 / 取消 → 自动泵下一条。 + * + * 设计要点: + * - runId 在入队时就分配(跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 active / waiting + * 两种状态都能精确定位 + * - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact + * - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent + * - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 + * + * 队列与运行态(waiting / active / 并发上限)是实例可变状态,故以 class 封装;PR 领域操作 + * (镜像 / diff base / adapter)经注入的 ctx.pr 取用。 + */ +export class RunQueue { + private readonly waiting: QueueItem[] = []; + /** 并发运行中的 run(runId → item);上限 maxConcurrency。 */ + private readonly active = new Map(); + private readonly maxConcurrency: number; + /** run 执行器(落盘 / worktree / spawn / 解析收尾);调度与执行分离,本类只负责并发 / 优先级 / 取消。 */ + private readonly executor: RunExecutor; + + constructor(private readonly ctx: ServiceContext) { + this.maxConcurrency = ctx.bootstrap.config.pr_agent.max_concurrency; + this.executor = new RunExecutor(ctx); + } + + /** + * 入队一个 pr-agent run(与用户手动 run 共用同一队列 / 并发 / 取消机制)。dedup:同 PR + * 同工具已在执行 / 排队则抛错(/ask 不限)。resolve 完成的 ReviewRun。 + */ + enqueuePragentRun( + pr: StoredPullRequest, + tool: ReviewRunTool, + question?: string, + priority: RunPriority = 'user', + referencedContext?: string, + referencedFinding?: ReviewRun['referencedFinding'], + ): Promise { + const { logger } = this.ctx; + if (tool !== 'ask') { + const sameTask = (q: QueueItem): boolean => + q.info.prLocalId === pr.localId && q.info.tool === tool; + if ([...this.active.values()].some(sameTask) || this.waiting.some(sameTask)) { + throw new AppError(ERROR_CODES.AG_DUPLICATE_TASK, { tool }); + } + } + // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 + const runId = makeRunId(new Date()); + return new Promise((resolve, reject) => { + const item: QueueItem = { + info: { + runId, + prLocalId: pr.localId, + repoSlug: pr.repo.repoSlug, + prNumber: pr.remoteId, + tool, + question: tool === 'ask' ? question : undefined, + enqueuedAt: new Date().toISOString(), + startedAt: null, + }, + // referencedContext / referencedFinding 仅入 req(内存态,不进 info/PragentRunInfo)→ 不进队列广播。 + // referencedFinding 会在 run-executor startRun 时落到 ReviewRun(前向链持久化)。 + req: { + localId: pr.localId, + tool, + question, + referencedContext: tool === 'ask' ? referencedContext : undefined, + referencedFinding: tool === 'ask' ? referencedFinding : undefined, + }, + pr, + priority, + resolve, + reject, + }; + // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 + if (priority === 'user') { + const firstAgentIdx = this.waiting.findIndex((q) => q.priority === 'agent'); + if (firstAgentIdx >= 0) this.waiting.splice(firstAgentIdx, 0, item); + else this.waiting.push(item); + } else { + this.waiting.push(item); + } + logger.info( + { runId, localId: pr.localId, tool, priority, queueLen: this.waiting.length }, + 'pragent run enqueued', + ); + this.pump(); + }); + } + + /** 取消一个 run(pragent:cancel):active→SIGKILL;waiting→出队 + reject;都不匹配→ok:false。 */ + cancel(runId: string): { ok: boolean } { + const { logger } = this.ctx; + // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) + const running = this.active.get(runId); + if (running) { + logger.info({ runId }, 'pragent run cancel: active'); + running.ac?.abort(); + return { ok: true }; + } + // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) + const idx = this.waiting.findIndex((q) => q.info.runId === runId); + if (idx >= 0) { + const [removed] = this.waiting.splice(idx, 1); + logger.info({ runId, queueLen: this.waiting.length }, 'pragent run cancel: queued'); + removed!.reject(new Error('queued run cancelled')); + this.broadcastQueueChanged(); + return { ok: true }; + } + return { ok: false }; + } + + /** 当前队列快照(pragent:queue / 广播用)。 */ + snapshot(): { active: PragentRunInfo[]; waiting: PragentRunInfo[] } { + return { + active: [...this.active.values()].map((q) => q.info), + waiting: this.waiting.map((q) => q.info), + }; + } + + /** 取消某 PR 的全部 run:active 的 SIGKILL,waiting 的出队 + reject。 */ + cancelRunsForPr(localId: string): void { + for (const item of this.active.values()) if (item.req.localId === localId) item.ac?.abort(); + let removed = false; + for (let i = this.waiting.length - 1; i >= 0; i--) { + if (this.waiting[i]!.req.localId === localId) { + const [q] = this.waiting.splice(i, 1); + q!.reject(new Error('pr removed')); + removed = true; + } + } + if (removed) this.broadcastQueueChanged(); + } + + /** active + waiting 涉及的 PR localId 集合(terminateAgentsForGonePrs 用)。 */ + queuedPrLocalIds(): string[] { + const ids: string[] = []; + for (const item of this.active.values()) ids.push(item.req.localId); + for (const item of this.waiting) ids.push(item.req.localId); + return ids; + } + + /** 应用退出时中止所有进行中的 run,返回被中止的 run 数。 */ + abortAllActiveRuns(): number { + let n = 0; + for (const item of this.active.values()) { + item.ac?.abort(); + n++; + } + return n; + } + + private broadcastQueueChanged(): void { + this.ctx.broadcast('pragent:queueChanged', this.snapshot()); + } + + /** + * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 + * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 + */ + private pump(): void { + while (this.active.size < this.maxConcurrency && this.waiting.length > 0) { + const item = this.waiting.shift()!; + this.active.set(item.info.runId, item); + item.ac = new AbortController(); + void this.executor + .execute(item, () => this.broadcastQueueChanged()) + .then((finished) => item.resolve(finished)) + .catch((err: unknown) => { + item.reject(err instanceof Error ? err : new Error(String(err))); + }) + .finally(() => { + this.active.delete(item.info.runId); + this.broadcastQueueChanged(); + // 放微任务里再泵,避免递归栈累积 + queueMicrotask(() => this.pump()); + }); + } + this.broadcastQueueChanged(); + } +} diff --git a/apps/desktop/src/main/services/pr-agent/usage.ts b/apps/desktop/src/main/services/pr-agent/usage.ts new file mode 100644 index 00000000..7b48bc63 --- /dev/null +++ b/apps/desktop/src/main/services/pr-agent/usage.ts @@ -0,0 +1,85 @@ +import type { TokenUsage } from '@meebox/shared'; + +// litellm usage 哨兵行前缀(与 sitecustomize.py 的 _emit 保持一致)。 +export const USAGE_SENTINEL = '@@MEEBOX_USAGE@@'; + +export interface UsageAcc { + prompt: number; + completion: number; + total: number; + calls: number; + /** 累计提示缓存读取 token(cache_read),是 prompt 的一部分 */ + cacheRead: number; + /** 累计模型交互轮次:CLI agentic 模式来自各次哨兵的 num_turns(一次 run 内可累加多段) */ + turns: number; + any: boolean; +} + +/** 新建一个空 usage 累加器。 */ +export function newUsageAcc(): UsageAcc { + return { prompt: 0, completion: 0, total: 0, calls: 0, cacheRead: 0, turns: 0, any: false }; +} + +/** + * 解析一行 stderr:若含 usage 哨兵(`@@MEEBOX_USAGE@@ {json}`,sitecustomize 注入)则累加到 + * acc 并返回 true(调用方据此吞掉该行、不转发给 renderer / 不入日志)。普通行返回 false。 + * 坏 JSON 也返回 true(仍吞掉,避免漏进实时日志),只是不计数。容错优先。 + */ +export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { + const i = line.indexOf(USAGE_SENTINEL); + if (i < 0) return false; + try { + const r = JSON.parse(line.slice(i + USAGE_SENTINEL.length).trim()) as { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + cache_read_tokens?: number; + turns?: number; + }; + acc.calls += 1; + if (typeof r.prompt_tokens === 'number') { + acc.prompt += r.prompt_tokens; + acc.any = true; + } + if (typeof r.completion_tokens === 'number') { + acc.completion += r.completion_tokens; + acc.any = true; + } + if (typeof r.total_tokens === 'number') { + acc.total += r.total_tokens; + acc.any = true; + } + if (typeof r.cache_read_tokens === 'number') acc.cacheRead += r.cache_read_tokens; + if (typeof r.turns === 'number') acc.turns += r.turns; + } catch { + // 坏哨兵行:仍吞掉,不计数 + } + return true; +} + +/** 累加器 → TokenUsage;无任何有效数据返回 undefined(未捕获到,如非 embedded / 流式 / 未调 LLM)。 */ +export function finalizeUsage(acc: UsageAcc): TokenUsage | undefined { + if (!acc.any) return undefined; + return { + promptTokens: acc.prompt, + completionTokens: acc.completion, + // 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底 + totalTokens: acc.total || acc.prompt + acc.completion, + calls: acc.calls, + // cache_read 无命中(0)则不带;turns 优先 CLI 上报的轮次,缺失回退为调用次数 + cacheReadTokens: acc.cacheRead || undefined, + turns: acc.turns || acc.calls, + }; +} + +/** + * 持久化前从 stderr 去掉 usage 哨兵行:onLine 实时已拦截不转发,但 exec 内部把全量 stderr + * 累加进 result.stderr(含哨兵),落盘前清掉这些噪声行。 + */ +export function stripUsageSentinels(stderr: string | undefined): string | undefined { + if (!stderr) return stderr; + return stderr + .split('\n') + .filter((l) => !l.includes(USAGE_SENTINEL)) + .join('\n'); +} diff --git a/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts b/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts new file mode 100644 index 00000000..e3f29a99 --- /dev/null +++ b/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { Logger } from 'pino'; + +/** + * CLI 模式下 /ask 会把子进程 cwd 落到一次性 worktree 以取得完整文件上下文(见 run-executor + + * pragent-shim cli/install.py)。但被评审仓库可能自带 agent 指令文件(claude / codex / gemini / + * cursor / copilot 的项目记忆),在 cwd 命中后会被 CLI 自动加载、污染 /ask 回答(含潜在 prompt 注入)。 + * + * 这里把 worktree 内这些指令文件**清空(present-but-blank)**:文件仍在、内容为空 → CLI 加载到空指令, + * 等同未配置。worktree 用后即弃(cleanup 直接 rm -rf),就地改写无副作用;pr-agent 的 diff 走 commit 级 + * merge-base,不读工作树状态,故清空不影响评审 diff。仅 /ask 走此路径,describe/review 维持中性临时目录。 + */ + +/** 按文件名匹配、任意层级都清空的项目记忆文件(claude / codex / gemini)。 */ +const INSTRUCTION_BASENAMES = new Set(['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules']); +/** 递归时跳过的目录(体积大 / 与指令无关)。 */ +const SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor']); +/** 根级固定路径的指令资源(相对 worktree 根,path.sep 归一)。 */ +const GITHUB_COPILOT = path.join('.github', 'copilot-instructions.md'); + +/** rel(相对 worktree 根)是否属于需清空的指令文件。 */ +function isInstructionFile(rel: string): boolean { + if (INSTRUCTION_BASENAMES.has(path.basename(rel))) return true; + if (rel === GITHUB_COPILOT) return true; + // cursor 规则目录 `.cursor/rules/**` 下任意文件。 + const parts = rel.split(path.sep); + if (parts[0] === '.cursor' && parts[1] === 'rules') return true; + return false; +} + +/** 递归收集 worktree 内全部文件路径(跳过 SKIP_DIRS)。 */ +async function collectFiles(dir: string, acc: string[]): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) await collectFiles(full, acc); + } else if (e.isFile()) { + acc.push(full); + } + } +} + +/** + * 清空一次性 worktree 内被评审仓库自带的 agent 指令文件。Best-effort:整体或单文件失败仅 warn, + * 不阻断 /ask(最坏退回「可能读到仓库指令」,不致命)。 + */ +export async function neutralizeWorktreeInstructions(dir: string, logger?: Logger): Promise { + try { + const files: string[] = []; + await collectFiles(dir, files); + let cleared = 0; + for (const full of files) { + if (!isInstructionFile(path.relative(dir, full))) continue; + try { + await fs.writeFile(full, ''); + cleared += 1; + } catch (err) { + logger?.warn({ err, file: full }, 'failed to neutralize repo instruction file'); + } + } + if (cleared > 0) + logger?.debug({ dir, cleared }, 'neutralized repo instruction files in worktree'); + } catch (err) { + logger?.warn( + { err, dir }, + 'neutralizeWorktreeInstructions failed; proceeding without sanitize', + ); + } +} diff --git a/apps/desktop/src/main/services/pr-service.ts b/apps/desktop/src/main/services/pr-service.ts new file mode 100644 index 00000000..dc3e74c1 --- /dev/null +++ b/apps/desktop/src/main/services/pr-service.ts @@ -0,0 +1,177 @@ +import type { BootstrapResult } from '@meebox/config'; +import { + isDiffBaseCacheReusable, + listStoredPullRequests, + readDiffBaseCache, + writeDiffBaseCache, +} from '@meebox/poller'; +import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; +import { + pullRequestHeadRefspec, + type PlatformAdapter, + type StoredPullRequest, +} from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { ConnectionRuntime } from '../adapters.js'; +import { broadcast } from './broadcast.js'; + +/** PrService 构造依赖(由 context 注入)。 */ +export interface PrServiceDeps { + bootstrap: BootstrapResult; + stateStore: JsonFileStateStore; + /** 可变连接运行时;reconfigure 原地替换内容,本服务经引用读到最新 adapters。 */ + connectionRuntime: ConnectionRuntime; + repoMirror: RepoMirrorManager; +} + +/** + * PR 领域服务:PR 定位 / 连接 adapter 解析 / 仓库镜像就位 / diff base 解析 / 评论缓存失效。 + * + * 把原先散落在 common/ 的 pr-lookup·mirror·comments-cache 收拢为单一强领域类,依赖经构造注入、 + * 各方法共享 `this.deps`,避免逐函数透传。controller 一律经 `ctx.pr.()` 调用;调用方 + * 应以实例方法形式调用(勿解构方法,否则丢失 this 绑定)。 + */ +export class PrService { + /** + * 按 localId 索引正在跑的 resolveDiffBaseSha。打开 PR 时 listChangedFiles / getFileContent / + * getBlame / listCommits / getCommitCount 等多个 handler 会并发解析同一 PR 的 diff-base:去重后 + * 只算一次 merge-base、只写一次 diff-base.json,避免对同一 key 的并发写(Windows 上会触发 rename + * EPERM,见 JsonFileStateStore 自愈)。 + */ + private readonly diffBaseInFlight = new Map>(); + + constructor(private readonly deps: PrServiceDeps) {} + + /** 按 localId 在状态库定位 PR,找不到抛错(统一错误文案)。 */ + async findPrOrThrow(localId: string): Promise { + const prs = await listStoredPullRequests(this.deps.stateStore); + const pr = prs.find((p) => p.localId === localId); + if (!pr) throw new Error(`PR not found in local state: ${localId}`); + return pr; + } + + /** PR → RepoIdentity(host / projectKey / repoSlug);connection 缺失抛错。 */ + repoIdentityFor(pr: StoredPullRequest): RepoIdentity { + const conn = this.deps.bootstrap.config.connections.find((c) => c.id === pr.connectionId); + if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); + return { + host: new URL(conn.base_url).hostname, + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + }; + } + + /** PR 对应连接的 adapter;连接无 adapter 时返回 undefined。 */ + adapterFor(pr: StoredPullRequest): PlatformAdapter | undefined { + return this.deps.connectionRuntime.adapters.find((a) => a.connectionId === pr.connectionId) + ?.adapter; + } + + /** 同 adapterFor,但无 adapter 时抛错(绝大多数 handler 走它)。 */ + adapterForOrThrow(pr: StoredPullRequest): PlatformAdapter { + const adapter = this.adapterFor(pr); + if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); + return adapter; + } + + /** + * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha + * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR + * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 + * + * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 + * + * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 + * 快速路径命中率应该很高。 + */ + async ensureMirrorReadyForPr( + pr: StoredPullRequest, + ): Promise<{ mirrorPath: string; freshClone: boolean }> { + const id = this.repoIdentityFor(pr); + const [hasHead, hasBase] = await Promise.all([ + this.deps.repoMirror.hasCommit(id, pr.sourceRef.sha), + this.deps.repoMirror.hasCommit(id, pr.targetRef.sha), + ]); + if (hasHead && hasBase) { + // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log + return { mirrorPath: this.deps.repoMirror.mirrorPath(id), freshClone: false }; + } + const r = await this.deps.repoMirror.syncMirror(id); + // 自愈:源分支被删 / 强推后 head sha 不在 refs/heads,syncMirror(只抓 heads + Bitbucket 通配 PR 引用) + // 仍补不齐 → 按平台 + PR 号精确 fetch PR 头引用(GitHub refs/pull//head 等,通配取不到,必须精确)。 + // 补齐后 diff base...head 才不报 "Invalid symmetric difference"。best-effort,仍缺则由下游 diff 抛可读错误。 + if (!(await this.deps.repoMirror.hasCommit(id, pr.sourceRef.sha))) { + const refspec = pullRequestHeadRefspec(pr.platform, pr.remoteId); + if (refspec) await this.deps.repoMirror.fetchRefspecs(id, [refspec]); + } + return { mirrorPath: r.mirrorPath, freshClone: r.freshClone }; + } + + /** + * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 + * + * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, + * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs//diff-base.json`, + * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: + * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; + * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 + * + * 失效重算: + * - 固化 base 不再是当前 head 的祖先(源分支被 rebase); + * - 当前 target 已经成为 head 的祖先,说明源分支把目标分支 merge 进来了,旧分叉点会把 merge + * 带来的目标分支内容也算进 PR diff。 + * 算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 + * + * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 + */ + async resolveDiffBaseSha(pr: StoredPullRequest): Promise { + // 并发去重:同一 PR 的多路并发解析复用同一 in-flight Promise,只算一次、只写一次 diff-base.json。 + const existing = this.diffBaseInFlight.get(pr.localId); + if (existing) return existing; + const promise = this.computeDiffBaseSha(pr).finally(() => { + this.diffBaseInFlight.delete(pr.localId); + }); + this.diffBaseInFlight.set(pr.localId, promise); + return promise; + } + + private async computeDiffBaseSha(pr: StoredPullRequest): Promise { + const id = this.repoIdentityFor(pr); + const head = pr.sourceRef.sha; + const cached = await readDiffBaseCache(this.deps.stateStore, pr.localId); + if ( + cached?.base_sha && + (await isDiffBaseCacheReusable({ + cachedBaseSha: cached.base_sha, + targetSha: pr.targetRef.sha, + headSha: head, + isAncestor: (ancestor, descendant) => + this.deps.repoMirror.isAncestor(id, ancestor, descendant), + })) + ) { + return cached.base_sha; + } + const mb = await this.deps.repoMirror.mergeBase(id, pr.targetRef.sha, head); + if (!mb) return pr.targetRef.sha; + await writeDiffBaseCache(this.deps.stateStore, pr.localId, { + base_sha: mb, + head_sha: head, + computed_at: new Date().toISOString(), + }); + return mb; + } + + /** + * 清掉某 PR 的评论缓存并广播 `comments:changed`,让 CommentsPanel / DiffView 内嵌评论重拉刷新。 + * 收口 comments reply/delete/edit 与 drafts:publishBatch 共用的链路(清 `prs//comments` + * 缓存 → 下次 listComments force 拉远端 → 广播触发重拉)。cache miss 无所谓,吞掉异常。 + */ + async invalidateCommentsCache(localId: string): Promise { + try { + await this.deps.stateStore.delete(`prs/${localId}/comments`); + } catch { + /* cache miss 也无所谓 */ + } + broadcast('comments:changed', { localId }); + } +} diff --git a/apps/desktop/src/main/utils/agent.ts b/apps/desktop/src/main/utils/agent.ts index f33b9af5..ede5b86f 100644 --- a/apps/desktop/src/main/utils/agent.ts +++ b/apps/desktop/src/main/utils/agent.ts @@ -8,155 +8,3 @@ export function resolveActiveLlmProfile(llm: { if (!llm.active_id) return null; return llm.profiles.find((p) => p.id === llm.active_id) ?? null; } - -/** - * 把 provider + 用户输入的 model 字符串规整成 litellm 期望的形式。 - * - * litellm 通过 model 字符串的前缀路由到对应 provider(`deepseek/...` → DeepSeek - * SDK,`anthropic/...` / `claude-*` → Anthropic,`openai/...` → OpenAI 兼容客户端, - * 无前缀 → 默认走 OpenAI)。用户在 LLM Profile 里只填模型名(如 `deepseek-v4-pro`), - * 这里按 provider 自动补前缀,避免 litellm 路由错到 OpenAI 用 `dummy_key` 报错。 - * - * 用户若手动写了带前缀的形式(兼容多 provider 用户 / 高级用户),不重复加。 - */ -function normalizeModel(provider: LlmProfile['provider'], model: string): string { - if (!model) return model; - const m = model.trim(); - switch (provider) { - case 'deepseek': - return m.startsWith('deepseek/') ? m : `deepseek/${m}`; - case 'anthropic': - // 一律补 `anthropic/` 前缀让 litellm 按前缀直接路由到 Anthropic。 - // 不能靠裸 `claude-*` 名字——litellm 只对**内置 model_cost 表里**的 claude - // 型号才能从名字反推 provider;新型号 (如 claude-opus-4-8) 不在表里,裸名传 - // 过去第一道 provider 路由就抛 "LLM Provider NOT provided"。带前缀则无需查表, - // 厂商原厂模型只填型号名即可直接用。用户手写带前缀的不重复加。 - return m.startsWith('anthropic/') ? m : `anthropic/${m}`; - case 'openai': - // 真 OpenAI:litellm 认 gpt-* / o1-* 等内置模型名;带 openai/ 前缀也直认。 - // 用户写的就是 litellm 内置表里的名字,不主动加前缀避免重复 (`openai/openai/...`) - return m; - case 'openai-compatible': - case 'dashscope': - case 'volcengine-ark': - // OpenAI 兼容协议(DashScope / 火山方舟 / 自部署 vLLM / 中转)— 模型 ID - // 是平台特定 (qwen-plus / doubao-pro-32k / ep-xxx endpoint id 等),**不在 - // litellm 内置 MAX_TOKENS 表里**,裸名传过去 litellm 第一道 provider 路由 - // 就报 "LLM Provider NOT provided"。 - // 必须显式 `openai/` 前缀让 litellm 走 "custom OpenAI client + 用 OPENAI_API_BASE - // 作为 endpoint" 分支,model 字段去前缀后透传给平台 - return m.startsWith('openai/') ? m : `openai/${m}`; - case 'cli': - // cli 模式完全绕过 litellm(shim 替换 chat_completion 直接调本机 CLI),model - // 字段是命令名 (claude) 不是 litellm 模型名,原样透传。CONFIG__MODEL 仅供 - // pr-agent 内部 token 估算用(未知名 → 走 custom_model_max_tokens 兜底)。 - return m; - default: - return m; - } -} - -/** - * 把单条 LLM Profile 翻成 pr-agent 认的环境变量。pr-agent 内部 TOML 配置 + - * 双下划线 env var 覆盖:`[openai] key = ...` ↔ `OPENAI__KEY=...`。 - * - * 走 env 而不是 `--openai.key=` CLI flag:避免密钥出现在 `ps` 进程列表 / - * git reflog;env 仅同用户在 /proc//environ 可见,相对安全。 - * - * 空字符串字段一律跳过——别覆盖 pr-agent 默认值或用户 shell 里已有的 env。 - * - * 此外三条防御性默认: - * - `CONFIG__MAX_MODEL_TOKENS=128000`:pr-agent **全局 input 上限**,默认 32000; - * 日志里 "tokens under limit: 32000" 来自这条。DeepSeek-v4 / 现代 Claude / GPT-4 - * 都是 128k 上下文,没必要被 pr-agent 强行截到 32k。设到 128k 让长 PR 能完整入 prompt - * - `CONFIG__CUSTOM_MODEL_MAX_TOKENS=128000`:pr-agent 的 MAX_TOKENS 内置表只覆盖少 - * 数主流模型,DeepSeek / 新 Claude / 自部署 / openai-compatible 都不在表里,跑起来 - * 报 "model not defined in MAX_TOKENS"。这条是 unknown 模型的兜底 - * - `CONFIG__FALLBACK_MODELS=[]`:pr-agent 默认配了 fallback (一般指向 OpenAI 系列), - * 主模型失败后会自动用 dummy key 试 OpenAI,污染日志且容易被误读成"配错了 OpenAI"。 - * 我们已经显式指定 provider,没有 fallback 的必要 - */ -export function buildPragentEnv(profile: LlmProfile): Record { - const env: Record = {}; - if (profile.model) env['CONFIG__MODEL'] = normalizeModel(profile.provider, profile.model); - env['CONFIG__MAX_MODEL_TOKENS'] = '128000'; - env['CONFIG__CUSTOM_MODEL_MAX_TOKENS'] = '128000'; - env['CONFIG__FALLBACK_MODELS'] = '[]'; - // litellm import 时会联网拉远端模型价格表(raw.githubusercontent.com),内网/弱网 - // 下 SSL 超时拖慢启动且刷警告。我们只取真实 token 数(来自 API response.usage), - // 不需要价格表 → 强制只用包内本地备份、彻底不联网。见 sitecustomize 的 usage callback。 - env['LITELLM_LOCAL_MODEL_COST_MAP'] = 'True'; - // 注:没接 LITELLM_LOG / CONFIG__VERBOSITY_LEVEL 因为 pr-agent 0.35 社区版上 - // 都不让 completion tokens 落到 stdout —— pr-agent 把它扔进 logger.debug 的 - // 'artifact' 字段,loguru 默认 INFO 级别滤掉。要拿到 completion tokens 需要走 - // sitecustomize / launcher monkey-patch litellm,独立于 env 实现 (留到后续) - switch (profile.provider) { - case 'openai': - case 'openai-compatible': - case 'dashscope': - case 'volcengine-ark': { - // 阿里百炼 / 火山方舟 / 自部署 vLLM 都暴露 OpenAI 兼容 endpoint。 - // - // 严格按 pr-agent 官方推荐 (docs/usage-guide/changing_a_model),只设双下划 - // 线 env: `OPENAI__KEY` / `OPENAI__API_BASE`。pr-agent 内部 - // (litellm_ai_handler.py) 会: - // litellm.openai_key = settings.openai.key - // litellm.api_base = settings.openai.api_base - // self.api_base = settings.openai.api_base - // 并在 `await acompletion(...)` 调用时无条件传 `api_base=self.api_base`。 - // - // 不要同时设单下划线 `OPENAI_API_KEY` / `OPENAI_BASE_URL` — OpenAI SDK 实例 - // 化时优先读这些环境变量,会把 pr-agent 注入的 `litellm.api_base` 覆盖掉, - // OpenAI client 改走 SDK 默认 endpoint,请求被打到 https://api.openai.com, - // DashScope key 必 401 (实测路径)。 - // - // model 仍需 `openai/<...>` 前缀 (normalizeModel 已加) — litellm 第一道 - // provider 路由按前缀认作 OpenAI-compatible client。裸 model 名 (qwen-plus) - // 不在 litellm.model_cost 表里,会抛 "LLM Provider NOT provided"。 - // - // dashscope / volcengine-ark 用 LLM_PROVIDERS 预设兜底 (跟 SettingsModal - // placeholder 同一份默认 endpoint),让历史 profile 留空时也能 work。 - // openai-compatible 不兜底 — 它是"自部署/中转代理"语义,endpoint 因人而异 - const baseUrlFallback: Record = { - dashscope: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'volcengine-ark': 'https://ark.cn-beijing.volces.com/api/v3', - }; - const effectiveBaseUrl = - profile.base_url || baseUrlFallback[profile.provider] || ''; - - if (profile.api_key) env['OPENAI__KEY'] = profile.api_key; - if (effectiveBaseUrl) env['OPENAI__API_BASE'] = effectiveBaseUrl; - break; - } - case 'deepseek': - // litellm 走 deepseek/ 路径;env 用 DEEPSEEK__KEY。base_url 一般无需填 - if (profile.api_key) env['DEEPSEEK__KEY'] = profile.api_key; - if (profile.base_url) env['DEEPSEEK__API_BASE'] = profile.base_url; - break; - case 'anthropic': - if (profile.api_key) env['ANTHROPIC__KEY'] = profile.api_key; - // base_url 必须走 litellm 原生 env `ANTHROPIC_API_BASE`(单下划线),**不能**用 - // pr-agent 风格的双下划线 `ANTHROPIC__API_BASE`:pr-agent 0.36 的 litellm_ai_handler - // 只读 settings.anthropic.key、不读 anthropic.api_base,对 anthropic 把 api_base=None - // 透传给 litellm.acompletion;litellm 的 get_api_base 仅在 api_base 为空时才回落到 - // ANTHROPIC_API_BASE / ANTHROPIC_BASE_URL(都没有才用官方 https://api.anthropic.com)。 - // litellm 默认会给 base 自动补 `/v1/messages`,故填到根域名即可、勿自带该后缀(中转端点 - // 本身已是完整路径时,另设 LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX=true 关掉自动补全)。 - if (profile.base_url) env['ANTHROPIC_API_BASE'] = profile.base_url; - break; - case 'cli': { - // 本地 CLI 模式:不直连任何 API,也不下发任何密钥。仅打两个哨兵 env 让 - // sitecustomize shim 在 pr-agent 进程内把 LiteLLMAIHandler.chat_completion - // 整体换成「调本机 CLI 子进程」版本(见 scripts/pragent-shim/meebox_pragent_shim/cli/)。 - // MEEBOX_CLI_MODE=1 —— 开关;非空即启用 CLI 接管 - // MEEBOX_CLI_BIN=claude —— 要调用的命令名(一期仅 claude;shim 用 which 解析真实路径) - // CLI 进程经子进程继承父 env(含 PATH / HOME),故能找到 claude 二进制并读到 - // ~/.claude 登录态。CONFIG__MODEL 已在上面置为命令名 (claude),仅用于 token 估算。 - const bin = (profile.model || 'claude').trim() || 'claude'; - env['MEEBOX_CLI_MODE'] = '1'; - env['MEEBOX_CLI_BIN'] = bin; - break; - } - } - return env; -} diff --git a/apps/desktop/src/main/utils/mac-path.ts b/apps/desktop/src/main/utils/mac-path.ts deleted file mode 100644 index 8834d09b..00000000 --- a/apps/desktop/src/main/utils/mac-path.ts +++ /dev/null @@ -1,46 +0,0 @@ -import os from 'node:os'; -import path from 'node:path'; - -/** - * macOS GUI(Finder / Dock / LaunchServices)启动的 app 由 launchd 给出**最小 PATH** - * (`/usr/bin:/bin:/usr/sbin:/sbin`),**不读用户 shell 配置**(`.zshrc` / `.zprofile`)。 - * 而本机 CLI(claude / codex)常装在 `~/.local/bin`、homebrew 等**只由 shell 往 PATH 里加**的 - * 目录——于是嵌入式 python 的 `shutil.which(...)` 找不到命令、本地 CLI provider 失效,但从终端 - * `npm run dev` 启动却正常(继承了已加载配置的终端 PATH)。Windows 不受影响(GUI 进程继承用户 PATH)。 - * - * 这里在启动期把常见 CLI 安装目录前置进 `process.env.PATH`(仅 darwin),之后所有子进程 - * (嵌入式 python 及其 spawn 的 CLI)都经 `{ ...process.env }` 继承到。静态目录已覆盖最常见的 - * 安装位置;不跑登录 shell 解析(避免启动期子进程 / 超时 / 噪声)。 - */ - -// 常见 CLI 安装目录:覆盖 pip --user / npm global / homebrew(Apple Silicon + Intel)。 -const COMMON_DIRS = [ - path.join(os.homedir(), '.local', 'bin'), - '/usr/local/bin', - '/opt/homebrew/bin', - '/opt/homebrew/sbin', -]; - -export interface MacPathResult { - /** 是否实际改写了 PATH(仅 darwin 且确有目录新增时为 true)。 */ - applied: boolean; - /** 本次新前置、原 PATH 中没有的目录(供日志诊断)。 */ - added: string[]; -} - -/** - * 仅 darwin:把常见目录前置进 `process.env.PATH`(去重,只补原 PATH 缺失的,保持原有顺序在后)。 - * 非 darwin 直接 no-op。返回补全详情供日志。 - */ -export function fixMacPath(): MacPathResult { - if (process.platform !== 'darwin') { - return { applied: false, added: [] }; - } - const existing = (process.env.PATH ?? '').split(':').filter(Boolean); - const existingSet = new Set(existing); - const added = COMMON_DIRS.filter((d) => !existingSet.has(d)); - if (added.length > 0) { - process.env.PATH = [...added, ...existing].join(':'); - } - return { applied: added.length > 0, added }; -} diff --git a/apps/desktop/src/main/utils/pr-context.ts b/apps/desktop/src/main/utils/pr-context.ts index 36663270..6e36f038 100644 --- a/apps/desktop/src/main/utils/pr-context.ts +++ b/apps/desktop/src/main/utils/pr-context.ts @@ -35,11 +35,11 @@ export async function buildPrContext({ }: BuildPrContextOpts): Promise { const sections: string[] = []; - sections.push(`**标题**: ${pr.title}`); + sections.push(`**Title**: ${pr.title}`); const desc = pr.description.trim(); if (desc) { - sections.push(`**描述**:\n${desc}`); + sections.push(`**Description**:\n${desc}`); } let comments: PrComment[] = []; @@ -57,7 +57,7 @@ export async function buildPrContext({ const formatted = formatComments(comments, maxComments, maxCommentLen); if (formatted.length > 0) { sections.push( - `**已有评论** (${String(formatted.length)} 条,最新在前):\n${formatted.join('\n')}`, + `**Existing comments** (${String(formatted.length)}, newest first):\n${formatted.join('\n')}`, ); } @@ -66,7 +66,7 @@ export async function buildPrContext({ if (sections.length === 1 && !desc && comments.length === 0) { return ''; } - return `## PR 上下文\n\n${sections.join('\n\n')}`; + return `## PR context\n\n${sections.join('\n\n')}`; } /** diff --git a/apps/desktop/src/main/utils/proxy.ts b/apps/desktop/src/main/utils/proxy.ts index 70ef090f..97d559bc 100644 --- a/apps/desktop/src/main/utils/proxy.ts +++ b/apps/desktop/src/main/utils/proxy.ts @@ -4,8 +4,7 @@ // - shouldBypass:loopback/本地是否直连(② 在调用点据此决定要不要挂 dispatcher) // 一期仅 HTTP 代理;enabled=false 时全部产出「空/直连」,调用点无需各自判断开关。 import { ProxyAgent, type Dispatcher } from 'undici'; -import type { ProxyConfig } from '@meebox/shared'; -import { t } from '../i18n/index.js'; +import { ERROR_CODES, errorCodeMessage, type ProxyConfig } from '@meebox/shared'; // loopback / 本地:始终直连,不经代理。env 路径靠 NO_PROXY,dispatcher 路径靠 shouldBypass。 const NO_PROXY = 'localhost,127.0.0.1,::1'; @@ -66,7 +65,7 @@ export async function testProxyConnectivity( proxy: ProxyConfig, ): Promise<{ ok: boolean; reason?: string }> { const dispatcher = buildProxyDispatcher(proxy); - if (!dispatcher) return { ok: false, reason: t('proxy.disabled') }; + if (!dispatcher) return { ok: false, reason: errorCodeMessage(ERROR_CODES.NT_PROXY_DISABLED) }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); try { @@ -74,7 +73,8 @@ export async function testProxyConnectivity( signal: ctrl.signal, dispatcher, } as RequestInit & { dispatcher: Dispatcher }); - if (res.status === 407) return { ok: false, reason: t('proxy.authFailed') }; + if (res.status === 407) + return { ok: false, reason: errorCodeMessage(ERROR_CODES.NT_PROXY_AUTH_FAILED) }; return { ok: true }; } catch (e) { return { ok: false, reason: e instanceof Error ? e.message : String(e) }; diff --git a/apps/desktop/src/main/utils/update-check.ts b/apps/desktop/src/main/utils/update-check.ts index 357171e1..fc0fd025 100644 --- a/apps/desktop/src/main/utils/update-check.ts +++ b/apps/desktop/src/main/utils/update-check.ts @@ -5,7 +5,6 @@ import type { ProxyConfig, UpdateCheckResult } from '@meebox/shared'; import { gt as semverGt, valid as semverValid } from 'semver'; import { proxyFetchForHost } from './proxy.js'; -import { t } from '../i18n/index.js'; const OWNER = 'huhamhire'; const REPO = 'code-meeseeks'; @@ -37,7 +36,7 @@ export async function checkForUpdate( // semver.valid 容忍前缀 v、拒绝尾部垃圾(1.2.3beta / 1.2.3.4 → null) const current = semverValid(currentVersion); - if (!current) return fail(t('update.parseCurrentFailed', { version: currentVersion })); + if (!current) return fail(`Unable to parse the current version: ${currentVersion}`); // 代理感知 fetch(命中本地/代理关闭则用全局 fetch 直连) const doFetch = proxyFetchForHost(proxy, API_HOST) ?? fetch; @@ -60,9 +59,9 @@ export async function checkForUpdate( return { ok: true, hasUpdate: false, currentVersion }; } const tag = data.tag_name; - if (!tag) return fail(t('update.missingTag')); + if (!tag) return fail('Release is missing tag_name'); const latest = semverValid(tag); - if (!latest) return fail(t('update.parseLatestFailed', { tag })); + if (!latest) return fail(`Unable to parse the latest version: ${tag}`); return { ok: true, hasUpdate: semverGt(latest, current), diff --git a/apps/desktop/src/main/utils/update-state.ts b/apps/desktop/src/main/utils/update-state.ts new file mode 100644 index 00000000..3d2984b5 --- /dev/null +++ b/apps/desktop/src/main/utils/update-state.ts @@ -0,0 +1,28 @@ +// 版本更新检测的**单一真相源**:手动检查(设置页 app:checkUpdate)与定时检查 +// (runUpdateCheckIfDue)都把结果交给这里,统一缓存 + 在确有新版时广播给所有窗口。 +// 这样手动查到的新版能同步到状态栏,且任意窗口 / 状态栏挂载时可经 app:getUpdateStatus 水合 +// 已知结果(不必等下一次广播 / 重新发起网络)。进程内缓存,不落盘——重启后由下次检查重填。 + +import { BrowserWindow } from 'electron'; +import type { UpdateCheckResult } from '@meebox/shared'; + +let lastResult: UpdateCheckResult | null = null; + +/** 最近一次**成功**(ok=true)的检测结果;尚未成功检测过时为 null。 */ +export function getLastUpdateResult(): UpdateCheckResult | null { + return lastResult; +} + +/** + * 记录一次检测结果并按需广播。失败(ok=false)不覆盖已知好结果、也不广播——保证 + * 「网络拿不到」对用户零打扰;成功结果(无论 hasUpdate)覆盖缓存,仅 hasUpdate 才推 + * app:updateAvailable(与既有「仅有新版才提示」的设计一致)。 + */ +export function publishUpdateResult(result: UpdateCheckResult): void { + if (!result.ok) return; + lastResult = result; + if (!result.hasUpdate) return; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('app:updateAvailable', result); + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a8a0d90a..486f0fa6 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -8,7 +8,7 @@ import type { IpcChannels, IpcEventName, IpcEvents, -} from '@meebox/shared'; +} from '@meebox/ipc'; console.log('[preload] script loaded'); diff --git a/apps/desktop/src/renderer/src/App.scss b/apps/desktop/src/renderer/src/App.scss index d09351b0..801115a7 100644 --- a/apps/desktop/src/renderer/src/App.scss +++ b/apps/desktop/src/renderer/src/App.scss @@ -1,32 +1,37 @@ // meebox renderer 样式总入口。各区域拆在 styles/ 下; // 设计 token 在 styles/_tokens.scss,每个 .scss 文件用 `@use './tokens' as *;` 拉取。 // -// base 全局 reset / html-body / .app / .btn / .muted / .badge 等通用原子 -// statusbar 底部 .app-statusbar + chips + LLM 切换菜单 +// base 全局 reset / html-body / .app / .btn / .muted / .activity-dot / .icon-btn 等通用原子 +// statusbar 底部状态栏基础壳 (.app-statusbar + .statusbar-chip 基类 + ok/err/update + spacer); +// 各业务域 chip 样式在 features/<域>/statusbar.scss // sidebar 左侧 PR 列表 (sidebar / pr-group / pr-item / avatar / review-chip) // main-pane .main / pr-header / pr-tabs / blame-toggle / blame-column -// file-tree DiffView 左侧文件树 (.diff-file-list / .tree-* / status dot) -// diff DiffView 主体 (.diff-pane-wrapper / sync-progress / error-boundary) -// diff-search DiffView sidebar 搜索模式 (.diff-search-panel / -input / -results) -// comment-zone Monaco 行内评论 view zone + glyph margin 标记 -// markdown react-markdown 输出的 h/p/li/code/pre/table 等 dark-theme 配色 +// diff/ DiffView 簇:view(主体) / search(搜索) / file-tree(文件树) / +// comment-zone(行内评论) / draft-zone(草稿编辑) +// common/ 共享渲染组件样式:markdown(react-markdown 输出) / mermaid(图) / +// bitbucket-image(图片缩放) / modal(模态壳) // pr-info "详情" tab 内容 (.pr-info-view / pr-detail-*) -// chat-pane 右侧 pr-agent chat 面板 +// chat/ 右侧 pr-agent chat 面板簇:chip(共享 chip 原子) / pane(外壳 + 输入) / +// run(run 状态输出) / findings(结果卡片) / agent(总结 + 思考 + 对话 + 空态) // drafts-panel "草稿" tab 列表页 (.drafts-panel / -filter / -item / -anchor) -// modal SettingsModal + LlmEditorModal 子模态 + LLM profile 列表 +// modal 通用模态壳 (.modal-backdrop / .modal / header / section / kv / footer / actions); +// 业务专属内容在 features/<域>/(settings/forms、pr/publish-review 等) +// config-picker 配置选择器左右布局 (平台 / LLM provider 选择列表 + 表单,向导与设置子模态共用) @use './styles/base'; -@use './styles/titlebar'; -@use './styles/statusbar'; -@use './styles/sidebar'; -@use './styles/main-pane'; -@use './styles/file-tree'; -@use './styles/diff'; -@use './styles/diff-search'; -@use './styles/comment-zone'; -@use './styles/draft-zone'; -@use './styles/markdown'; -@use './styles/pr-info'; -@use './styles/chat-pane'; -@use './styles/drafts-panel'; -@use './styles/modal'; -@use './styles/onboarding'; +@use './styles/layout/titlebar'; +@use './styles/layout/statusbar'; +@use './styles/layout/sidebar'; +@use './styles/layout/main-pane'; +@use './styles/features/diff'; +@use './styles/common/markdown'; +@use './styles/common/mermaid'; +@use './styles/common/bitbucket-image'; +@use './styles/features/pr-info'; +@use './styles/features/chat'; +@use './styles/features/drafts-panel'; +@use './styles/common/modal'; +@use './styles/features/pr'; // pr 簇(含 publish-review,须在 common/modal 之后) +@use './styles/features/settings/forms'; +@use './styles/features/config-picker'; +@use './styles/features/onboarding'; +@use './styles/features/settings/statusbar' as settings-statusbar; diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 9fdfa858..850d94ee 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,365 +1,69 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import i18n, { resolveUiLanguage, persistLanguage } from './i18n'; -import type { - AppInfo, - AppPaths, - Config, - ConnectionSummary, - LocalPrStatus, - PrAgentStatus, - PrDiscoveryFilter, - StoredPullRequest, - UpdateCheckResult, -} from '@meebox/shared'; -import { invoke, subscribe } from './api'; -import { ChatPane, CHAT_MAX_WIDTH, CHAT_MIN_WIDTH } from './components/ChatPane'; -import { wireChatRunStore } from './stores/chat-run-store'; -import { wireDraftsStore } from './stores/drafts-store'; -import { wireRepoSyncStore } from './stores/repo-sync-store'; -import { MainPane } from './components/MainPane'; -import { OnboardingWizard, type OnboardingResult } from './components/onboarding/OnboardingWizard'; -import { SettingsModal } from './components/SettingsModal'; -import { Sidebar, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from './components/Sidebar'; -import { StatusBar } from './components/StatusBar'; -import { TitleBar } from './components/TitleBar'; - -interface BootstrapState { - info: AppInfo; - paths: AppPaths; - config: Config; - prAgent: PrAgentStatus; - connections: ConnectionSummary[]; - lastSyncAt: string | null; -} +import type { PrDiscoveryFilter } from '@meebox/shared'; +import { invoke } from './api'; +import { ChatPane } from './components/features/chat'; +import { MainPane } from './components/layout/MainPane'; +import { PrPanel, PrEmpty, usePullRequests } from './components/features/pr'; +import { OnboardingWizard } from './components/features/onboarding'; +import { SettingsModal } from './components/features/settings'; +import { Sidebar } from './components/layout/Sidebar'; +import { StatusBar } from './components/layout/StatusBar'; +import { TitleBar } from './components/layout/TitleBar'; +import { useToast } from './hooks/useToast'; +import { useBootstrap } from './hooks/useBootstrap'; +import { usePanelLayout } from './hooks/usePanelLayout'; +import { useUpdateNotice } from './hooks/useUpdateNotice'; +import { useAppStores } from './hooks/useAppStores'; +import { useExternalLinkGuard } from './hooks/useExternalLinkGuard'; export default function App() { const { t } = useTranslation(); - const [boot, setBoot] = useState(null); - const [prs, setPrs] = useState([]); - const [selectedId, setSelectedId] = useState(null); - const [refreshing, setRefreshing] = useState(false); - // 合并进行中:GitHub 合并可能较慢(异步算 mergeable),按钮置等待态并防重复点击。 - const [merging, setMerging] = useState(false); + const { toast, notifyError, dismiss: dismissToast } = useToast(); + // PR 列表 / 选中 / 审批 / 合并 / 刷新 —— 领域逻辑归 usePullRequests + const { + prs, + setPrs, + selectedId, + setSelectedId, + selected, + refreshing, + merging, + reloadPrs, + triggerRefresh, + setSelectedPrStatus, + mergeSelectedPr, + } = usePullRequests({ notifyError }); + // 应用启动 / 全局生命周期(boot 加载、语言、poll / focus 刷新、向导完成、连接热生效) + const { boot, fatalError, lastSyncAt, needsOnboarding, completeOnboarding, refreshBootAndPrs, patchConfig } = + useBootstrap({ setPrs, reloadPrs }); + // 布局态(左右两栏宽度 / 折叠)、版本更新提示、store 接线、外链防护——各自成 app 级 hook + const { + sidebarWidth, + setSidebarWidth, + sidebarCollapsed, + setSidebarCollapsed, + chatWidth, + setChatWidth, + chatCollapsed, + setChatCollapsed, + } = usePanelLayout(); + const updateInfo = useUpdateNotice(); + useAppStores(); + useExternalLinkGuard(); + const [showSettings, setShowSettings] = useState(false); - const [fatalError, setFatalError] = useState(null); - // 启动检测到的新版本(main 推 app:updateAvailable);StatusBar 据此提示跳转下载。 - const [updateInfo, setUpdateInfo] = useState(null); - // 操作级 toast(审批 / 合并等远端动作失败时提示,区别于 fatalError 整屏报错)。 - // key 用随机数:同样文案连续触发也能重置自动消失计时器。 - const [toast, setToast] = useState<{ text: string; key: number } | null>(null); - const notifyError = useCallback((text: string): void => { - setToast({ text, key: Math.random() }); - }, []); - // toast 自动消失(6s);key 变化即重置计时 - useEffect(() => { - if (!toast) return; - const id = setTimeout(() => setToast(null), 6000); - return () => clearTimeout(id); - // 仅依赖 key:同一 toast 重渲不重置计时,新 toast (key 变) 才重置 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toast?.key]); - // 仅调试用:localStorage 里 meebox.forceOnboarding='1' 时强制进首启向导, - // 不必动 config.yaml。DevTools 设值后刷新进入;走完向导会自动清掉该 flag。 - // 详见 docs/development.md。 - const [forceOnboarding, setForceOnboarding] = useState( - () => localStorage.getItem('meebox.forceOnboarding') === '1', - ); /** - * M4 跨组件跳转:ChatPane finding card 点"编辑" → 这里 set → - * MainPane 切 tab='diff' + 透传给 DiffView → DiffView 消费完调 onConsumed 清空。 - * 一次性 token;非 null 时 DiffView 应该 scroll + highlight (+ open edit zone - * 如果带 runId/findingId 能反查到 finding-source 草稿)。 - * - * runId/findingId 可选: - * - ChatPane finding card 跳转 → 必带,DiffView 据此找草稿自动 enter edit - * - PublishReviewModal anchor 点击 → 只带 anchor,DiffView 仅 navigate 不进 edit - * (用户在 modal 里看到某条想确认上下文,跳过去看一眼,不一定要改) + * M4 跨组件跳转:ChatPane finding card 点"编辑" / PublishReviewModal anchor 点击 → 这里 set → + * PrPanel 切到 Diff tab + 透传给 DiffView 做 scroll/highlight/(可选)open edit zone,消费完清空。 */ const [pendingDiffNav, setPendingDiffNav] = useState<{ runId?: string; findingId?: string; anchor: { path: string; startLine: number; endLine: number }; } | null>(null); - const [lastSyncAt, setLastSyncAt] = useState(null); - // GitHub 发现分类(运行时筛选,不持久化);仅 GitHub 活动连接时在 PR 列表展示。 + // GitHub 发现分类(运行时筛选,不持久化);仅活动连接支持时在 PR 列表展示。 const [discoveryFilter, setDiscoveryFilter] = useState('review-requested'); - const [sidebarWidth, setSidebarWidth] = useState(() => { - const raw = localStorage.getItem('meebox.sidebarWidth'); - const n = raw ? Number(raw) : 360; - return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Number.isFinite(n) ? n : 360)); - }); - const [sidebarCollapsed, setSidebarCollapsed] = useState( - () => localStorage.getItem('meebox.sidebarCollapsed') === '1', - ); - const [chatWidth, setChatWidth] = useState(() => { - const raw = localStorage.getItem('meebox.chatWidth'); - const n = raw ? Number(raw) : 360; - return Math.min(CHAT_MAX_WIDTH, Math.max(CHAT_MIN_WIDTH, Number.isFinite(n) ? n : 360)); - }); - const [chatCollapsed, setChatCollapsed] = useState( - // 默认收起:M3 之前 chat 还是空壳,避免空占地方 - () => (localStorage.getItem('meebox.chatCollapsed') ?? '1') === '1', - ); - useEffect(() => { - localStorage.setItem('meebox.sidebarWidth', String(sidebarWidth)); - }, [sidebarWidth]); - useEffect(() => { - localStorage.setItem('meebox.sidebarCollapsed', sidebarCollapsed ? '1' : '0'); - }, [sidebarCollapsed]); - useEffect(() => { - localStorage.setItem('meebox.chatWidth', String(chatWidth)); - }, [chatWidth]); - useEffect(() => { - localStorage.setItem('meebox.chatCollapsed', chatCollapsed ? '1' : '0'); - }, [chatCollapsed]); - - const reloadPrs = useCallback(async (): Promise => { - const fresh = await invoke('prs:list', undefined); - setPrs(fresh); - }, []); - - // 连接改动(尤其切换活动连接)后整体刷新 boot:活动连接变化后 main 端 app:connections / - // prs:list 都随之变,必须重拉,否则 boot.connections、PR 列表会过期。 - const refreshBootAndPrs = useCallback(async (): Promise => { - const [config, connections, freshPrs, lastSync] = await Promise.all([ - invoke('config:read', undefined), - invoke('app:connections', undefined), - invoke('prs:list', undefined), - invoke('prs:lastSync', undefined), - ]); - setBoot((b) => (b ? { ...b, config, connections, lastSyncAt: lastSync.at } : b)); - setPrs(freshPrs); - setLastSyncAt(lastSync.at); - }, []); - - useEffect(() => { - void (async () => { - try { - if (!window.api) { - throw new Error('preload bridge missing: window.api is undefined'); - } - const [info, paths, config, prAgent, initialPrs, connections, lastSync] = await Promise.all( - [ - invoke('app:info', undefined), - invoke('app:paths', undefined), - invoke('config:read', undefined), - invoke('app:prAgentStatus', undefined), - invoke('prs:list', undefined), - invoke('app:connections', undefined), - invoke('prs:lastSync', undefined), - ], - ); - // 先按 config.language 切到目标语言并**等其资源加载完**(懒加载语言会异步拉 chunk), - // 再 setBoot 渲染主界面 —— 首屏直接是用户语言,不闪兜底。persist 供下次启动同步命中。 - const lang = resolveUiLanguage(config.language); - persistLanguage(lang); - await i18n.changeLanguage(lang); - setBoot({ info, paths, config, prAgent, connections, lastSyncAt: lastSync.at }); - setPrs(initialPrs); - setLastSyncAt(lastSync.at); - } catch (e) { - setFatalError(e instanceof Error ? e.message : String(e)); - } - })(); - }, []); - - // 运行时语言切换(如设置页改 config.language):boot 后 language 变化即切换并回写持久化。 - // 首次 boot 时已在上面 await 切好,这里对同值是幂等 no-op。 - useEffect(() => { - if (!boot) return; - const lang = resolveUiLanguage(boot.config.language); - persistLanguage(lang); - void i18n.changeLanguage(lang); - }, [boot]); - - // 启动时把 pr-agent 活动 run + 实时 stdout 流接到全局 store;ChatPane 跨 PR - // 切换时可读 store 拿回运行中的状态 (本组件挂载到树根,效果等价于"应用级 hook") - useEffect(() => wireChatRunStore(), []); - // 同样思路:把 repo sync 事件流接到 store,StatusBar 任意时刻可读当前活动同步任务 - useEffect(() => wireRepoSyncStore(), []); - // M4 草稿事件 → store;写盘后 drafts:changed 触发指定 PR 的草稿列表自动刷新 - useEffect(() => wireDraftsStore(), []); - // 启动版本更新检测:main 仅在有新版时推 app:updateAvailable - useEffect(() => subscribe('app:updateAvailable', (info) => setUpdateInfo(info)), []); - // dev 调试钩子:控制台 dispatch CustomEvent 模拟「发现新版」以验证状态栏 chip - // (dev 版本通常高于 latest,自然不会触发)。detail=null 清除。 - // window.dispatchEvent(new CustomEvent('meebox:debug-update')) - // window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: { latestVersion: '1.2.3' } })) - // window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: null })) - useEffect(() => { - const onDebug = (e: Event): void => { - const d = (e as CustomEvent | null>).detail; - setUpdateInfo( - d === null - ? null - : { - ok: true, - hasUpdate: true, - currentVersion: '0.0.0', - latestVersion: '9.9.9', - url: 'https://github.com/huhamhire/code-meeseeks/releases/latest', - ...d, - }, - ); - }; - window.addEventListener('meebox:debug-update', onDebug); - return () => window.removeEventListener('meebox:debug-update', onDebug); - }, []); - - // 全局外链跳转防护 — 所有 UGC 场景 (评论 / PR 描述 / finding / chat 等) 内 - // 的 点击都走系统默认浏览器,不允许 Electron 在 app - // window 内直接跳转覆盖整个界面。capture 阶段 listener 先于 React onClick 跑 - useEffect(() => { - const onClick = (e: MouseEvent): void => { - const target = (e.target as HTMLElement | null)?.closest?.('a[href]'); - if (!(target instanceof HTMLAnchorElement)) return; - const href = target.getAttribute('href'); - if (!href || !/^https?:\/\//.test(href)) return; - e.preventDefault(); - e.stopPropagation(); - void invoke('app:openExternal', { url: href }); - }; - document.addEventListener('click', onClick, true); - return () => document.removeEventListener('click', onClick, true); - }, []); - - // 窗口重新获得焦点时主动 refresh 远端:调 prs:refresh 拉 PR meta,Bitbucket 上 - // 加 comment / 改状态后 PR.updatedAt 跳变 → MainPane useEffect 的 prUpdatedAt - // dep 触发 → force listComments 拉到新评论。比纯 reloadPrs (只读 cache) 多 - // 一次远端调用但能跟上"用户切到 Bitbucket 评论再切回应用"的常见场景 - useEffect(() => { - const onFocus = (): void => { - if (!boot) return; - void (async () => { - try { - await invoke('prs:refresh', undefined); - await reloadPrs(); - } catch { - // 静默:focus 触发的刷新失败不该弹错给用户 - } - })(); - }; - window.addEventListener('focus', onFocus); - return () => window.removeEventListener('focus', onFocus); - }, [boot, reloadPrs]); - - // 订阅 main 推送的 poll tick;用于刷新 statusbar "最近同步" 显示, - // 并顺便重拉一次 PR 列表使后台轮询新增/删除立刻反映在 UI。 - // 同时刷新连接摘要:启动时连接的 ping(缓存 currentUser)在建窗后才完成,首轮 tick 即随其后, - // 借此把状态栏用户/能力位补上(否则需手动刷新才显示)。app:connections 为廉价同步调用。 - useEffect(() => { - if (!window.api) return; - return subscribe('poll:tick', (info) => { - setLastSyncAt(info.at); - void reloadPrs(); - void invoke('app:connections', undefined).then( - (connections) => { - setBoot((b) => (b ? { ...b, connections } : b)); - }, - () => { - /* 摘要刷新失败不影响主流程 */ - }, - ); - }); - }, [reloadPrs]); - - const triggerRefresh = useCallback(async (): Promise => { - if (refreshing) return; - setRefreshing(true); - try { - await invoke('prs:refresh', undefined); - await reloadPrs(); - } catch (e) { - console.error('refresh failed', e); - } finally { - setRefreshing(false); - } - }, [refreshing, reloadPrs]); - - const selected = prs.find((p) => p.localId === selectedId) ?? null; - // 选中 PR 所属连接的能力位 + 当前 PAT 用户(多平台降级:审批按钮显隐 / 自己 PR 灰显) - const selectedConn = selected - ? boot?.connections.find((c) => c.connectionId === selected.connectionId) - : undefined; - - const setSelectedPrStatus = useCallback( - async (status: LocalPrStatus): Promise => { - if (!selected) return; - try { - const updated = await invoke('prs:setLocalStatus', { - localId: selected.localId, - status, - }); - if (updated) { - setPrs((prev) => prev.map((p) => (p.localId === updated.localId ? updated : p))); - } - } catch (e) { - // 远端拒绝(如 PR 已关闭 / 合并 / 权限不足)→ 本地状态不变,弹 toast 提示。 - // 顺手刷新一次:PR 若已关闭,下一轮 poll 会把它软删,列表自洽 - const msg = e instanceof Error ? e.message : String(e); - notifyError(t('app.approveActionFailed', { msg })); - void triggerRefresh(); - } - }, - [selected, notifyError, triggerRefresh, t], - ); - - const mergeSelectedPr = useCallback(async (): Promise => { - if (!selected || merging) return; - const mergedId = selected.localId; - setMerging(true); - try { - await invoke('prs:merge', { localId: mergedId }); - } catch (e) { - // 合并失败(冲突 / veto / 权限 / PR 已关闭)→ 弹 toast,本地不变 - const msg = e instanceof Error ? e.message : String(e); - notifyError(t('app.mergeFailed', { msg })); - void triggerRefresh(); - return; - } finally { - setMerging(false); - } - // 合并成功:PR 已转 MERGED,会从 pending 列表退场。取消选中 + 刷新让其消失 - if (selectedId === mergedId) setSelectedId(null); - await triggerRefresh(); - }, [selected, selectedId, triggerRefresh, notifyError, merging, t]); - - // 首启向导完成:落盘连接(必)+ LLM / 缓存目录(按需),再重拉配置/连接/PR 更新 - // boot。boot.config 拿到有效 active 连接后,下方 needsOnboarding 派生为 false, - // 向导自然卸载、切入主界面;主界面挂载后 poll:tick 订阅 + focus 刷新自然生效。 - const completeOnboarding = useCallback( - async (result: OnboardingResult): Promise => { - await invoke('config:setConnections', { - connections: [result.connection], - active_connection_id: result.connection.id, - }); - if (result.llm) { - await invoke('config:setLlm', { - llm: { profiles: [result.llm], active_id: result.llm.id }, - }); - } - const trimmedRepos = result.reposDir.trim(); - if (trimmedRepos && trimmedRepos !== (boot?.config.workspace.repos_dir ?? '')) { - await invoke('config:setReposDir', { reposDir: trimmedRepos }); - } - const [config, connections, freshPrs, lastSync] = await Promise.all([ - invoke('config:read', undefined), - invoke('app:connections', undefined), - invoke('prs:list', undefined), - invoke('prs:lastSync', undefined), - ]); - setBoot((b) => (b ? { ...b, config, connections, lastSyncAt: lastSync.at } : b)); - setPrs(freshPrs); - setLastSyncAt(lastSync.at); - // 走完向导清掉调试 flag,避免强制模式下完成后仍被困在向导 - if (forceOnboarding) { - localStorage.removeItem('meebox.forceOnboarding'); - setForceOnboarding(false); - } - }, - [boot, forceOnboarding], - ); if (fatalError) { return ( @@ -368,7 +72,6 @@ export default function App() { ); } - if (!boot) { return (