diff --git a/.editorconfig b/.editorconfig index 3026ff88..c3bba3c4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,6 +27,15 @@ indent_size = 2 indent_style = space indent_size = 4 +# Go 由 gofmt 统一格式化:缩进恒用 tab(非空格),此处仅约定 tab 的显示宽度为 4。 +[*.go] +indent_style = tab +tab_width = 4 + +[go.mod] +indent_style = tab +tab_width = 4 + [{Makefile,makefile,GNUmakefile,*.mk}] indent_style = tab tab_width = 4 diff --git a/.github/workflows/ci-cli.yml b/.github/workflows/ci-cli.yml new file mode 100644 index 00000000..2e03888d --- /dev/null +++ b/.github/workflows/ci-cli.yml @@ -0,0 +1,46 @@ +name: CLI + +# meebox CLI(cli/,独立 Go module)的门禁,与 Node/Nx 的 CI 分开: +# 路径过滤只能加在 workflow 的 on 层(不能按 job 过滤),故独立成一条流水线——仅当 cli/ 变更时才跑, +# 既隔离 Go 工具链、又省 CI 分钟。发布期的交叉编译 / 出包见 release.yml 的 cli job。 +on: + push: + branches: [master] + paths: + - 'cli/**' + - '.github/workflows/ci-cli.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/ci-cli.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + cli: + name: Vet + Test + Build + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: cli + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0529500b..b708155a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ concurrency: cancel-in-progress: false jobs: - build: + gui: strategy: fail-fast: false matrix: @@ -137,3 +137,72 @@ jobs: make_latest: ${{ !contains(github.ref_name, '-') }} # 正文 = RELEASE_NOTES(安装 / 首次打开 / 校验和)+ 注入的本版 CHANGELOG 段 body_path: RELEASE_BODY.md + + # meebox CLI(cli/,独立 Go module):纯 Go、无 CGO → 单 runner 交叉编译全平台。出四平台压缩包 + + # 校验和,随桌面安装包挂到同一个 GitHub Release(不打进安装包,是独立可分发物)。仅 tag 触发上传; + # workflow_dispatch 仍构建做编译冒烟、不上传。本 job 不设 Release 正文(由 build job 注入),仅追加产物。 + cli: + name: CLI (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { goos: windows, goarch: amd64, ext: '.exe', archive: zip } + - { goos: darwin, goarch: arm64, ext: '', archive: zip } + - { goos: linux, goarch: amd64, ext: '', archive: tar.gz } + - { goos: linux, goarch: arm64, ext: '', archive: tar.gz } + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: 交叉编译 meebox CLI + shell: bash + working-directory: cli + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: | + # 版本与 app 同源:取自 apps/desktop/package.json(app 运行期版本的唯一真相源), + # 而非独立依赖 git tag——发布前置已校验 tag == 该版本,故二者一致但只有一个来源。 + VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" + mkdir -p dist + go build -trimpath \ + -ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \ + -o "dist/meebox${{ matrix.ext }}" . + + - name: 打包压缩包 + 校验和 + if: startsWith(github.ref, 'refs/tags/') + shell: bash + working-directory: cli/dist + run: | + VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # 与 app 同源 + BIN="meebox${{ matrix.ext }}" + ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" + # Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill + # directory (unzip into a skills dir → SKILL.md beside the binary it drives). + cp ../../LICENSE ../README.md ../SKILL.md . + FILES=("${BIN}" LICENSE README.md SKILL.md) + if [ "${{ matrix.archive }}" = "zip" ]; then + zip -q "${ARCHIVE}.zip" "${FILES[@]}" + else + tar -czf "${ARCHIVE}.tar.gz" "${FILES[@]}" + fi + for f in "${ARCHIVE}".zip "${ARCHIVE}".tar.gz; do + [ -e "$f" ] && sha256sum "$f" > "$f.sha256" + done + + - name: 上传到 Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + # 本 matrix 项仅产出自身的压缩包 + .sha256(fresh runner);不设 body_path 以免覆盖 build job 注入的正文 + files: cli/dist/meebox-cli-* + fail_on_unmatched_files: true + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ !contains(github.ref_name, '-') }} diff --git a/AGENTS.md b/AGENTS.md index db4339ea..12f6e5d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,25 +4,26 @@ ## 仓库结构 -Electron 桌面应用 + npm workspaces + Nx 单仓多包。关键路径(`apps/desktop` 是主战场): +Electron 桌面应用(npm workspaces + Nx 单仓多包)外加一个独立 Go CLI 子工程。关键路径(`apps/desktop` 是主战场): ``` -apps/desktop/ +apps/desktop/ # Electron 应用(唯一有 build/dist 的项目) ├── src/ -│ ├── main/ # 主进程:index.ts(启动/单例锁) · ipc.ts(IPC handlers) · adapters.ts · utils/ +│ ├── main/ # 主进程:业务与 IO 唯一所在(启动/单例锁 · IPC handlers · 平台适配 · 服务层:pr · agent 编排 · 本地 API) │ ├── preload/ # contextBridge 暴露泛型 invoke() -│ └── renderer/src/ # React 渲染层(components/ 等) -├── scripts/ # assemble-pragent-runtime.mjs · pragent-shim/(shim) · pragent-runtime.json -├── build-resources/ # after-pack.cjs(ad-hoc 签名) · entitlements.mac.plist -├── electron-builder.yml -└── vendor/pragent/ # 嵌入式运行时(gitignored,由 prepare:pragent 生成) +│ └── renderer/src/ # React 渲染层(UI / 交互) +├── scripts/ # 嵌入式 pr-agent 运行时组装 + monkeypatch shim +├── build-resources/ # electron-builder 打包资源(签名钩子 · entitlements) +└── vendor/pragent/ # 嵌入式运行时(gitignored,prepare:pragent 生成) -packages//src/index.ts # 各库入口;shared 还含 ipc.ts(IPC 契约) · config.ts · poller-contract.ts +packages// # 内部库 @meebox/*(各 src/index.ts 为入口);shared 含共享类型 + IPC 契约 +cli/ # 独立 Go module:跨平台 CLI meebox(命令树 + HTTP client;经本地 API 集成,不入 npm/Nx) ``` - `apps/desktop` —— Electron 应用(main + preload + renderer/React)。唯一有 `build`/`dist` 的项目。 - `packages/*` —— 内部库(`@meebox/*`),按职责拆分;其中 `shared` 含共享类型与 IPC 契约。 -- `docs/arch/` 各模块设计文档(首选入口);`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 +- `cli/` —— 独立分发的 Go 命令行工具 `meebox`(外部集成用,不属 npm/Nx;详见下「CLI 工程(cli/)」段)。 +- `docs/arch/` 各模块设计文档(首选入口);`docs/guide/` 使用说明;`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 **命名约定**:代码内部统一用中性代号 `meebox`(npm 作用域 `@meebox/*`);对外品牌名 `Code Meeseeks`;用户数据目录 `~/.code-meeseeks/`。`pr-agent` 为第三方依赖,不在重命名范围内。 @@ -67,17 +68,22 @@ 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: +发版:`dev` 汇入 `master` → 在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + CLI 二进制 + GitHub 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]` 改名为 `## [<版本>] - `,并在文件底部补 `[<版本>]: …/compare/…` 链接引用(仿现有行)。**发布即移除 Unreleased、不再另起空段**——`## [Unreleased]` 仅**开发期**存在以累积变更,发布时被改名消费掉;下一笔开发期 changelog 改动时再新建一个 `## [Unreleased]`(见「版本号规则」的 `-dev` 开发态)。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文的「版本变更」区——缺这段则正文回退、无任何变更说明。**若本次是正式版(无 `-` 后缀)、且其内容来自此前的 alpha/预发布**:此时开发期通常无独立 Unreleased(内容已在预发布段),直接把该预发布段改名为正式版段、删去对应的 `[-alpha.N]:` 链接引用即可(内容并入正式版段,不再保留空壳 stub);尚无对应正式版的其它预发布段保留。 -3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(新增 / 变更 / 修复)。 +⚠️ **打 tag 前必须在同一批改动里完成三步前置(版本号 / CHANGELOG / 校对),随发版一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release。**完整前置清单、`-dev` 版本号规则、CHANGELOG 撰写风格见 [打包与发布](docs/development/packaging-release.md)**。tag 名须等于 package.json 版本(`v<版本>`);名含 `-` 的预发布 tag 自动标 prerelease、不抢占 Latest。 -tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag(名含 `-`,如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 +## CLI 工程(cli/) -**版本号规则**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,并 `npm install` 同步 lockfile),标记开发态、与正式版区分。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面三步前置把它改成目标号(预发布 `0.7.0-alpha.N` 或正式 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range,故无「预发布不满足范围」陷阱)与构建。 +`cli/` 是独立分发的跨平台命令行客户端 `meebox`(供外部 agent / 脚本经[本地 API 服务](docs/arch/04-integration/01-service-api.md)集成)。设计见 [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md),用法见 [docs/guide/06-cli.md](docs/guide/06-cli.md)。 -**CHANGELOG 撰写风格**(面向用户、求简):① 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**;② 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止;③ 重构类任务**前后端合并**为一条总结、不展开实现细节;④ 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响;⑤ 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良;⑥ **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险;⑦ **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security)。外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 +- **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 +- **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 +- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本**取自 `apps/desktop/package.json`(与 app 同源,即 app 运行期版本的唯一真相源)**经 `-ldflags -X …/cmd.version` 注入——不独立依赖 git tag(发布前置已校验 tag == 该版本)。 +- **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。 +- **写边界**:CLI 做浏览 + **评审写动作**——approve / needswork(远端评审决断)与 comment(发评论),经服务端专用端点(复用 GUI 同源 controller)。仍**不开放**:merge(合并)与 pr-agent 变更类工具(publish 等,`instruct` 只读白名单 describe/review/ask/improve 在 CLI 与服务端双重把关)。新增命令先确认对应 API 端点已存在;放开新写端点须评估远端副作用。CLI 不得绕过 API 直连应用内部。 +- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向、保序)、`--output json` 供机器(亦保序);PR 列表返回精简投影、PR 标识对外为 `id`、PR 关联命令用 `--pr `。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 +- **领域归类**:CLI / API / GUI 都是同一 service 层之上的薄前端,命令树与端点应**镜像业务领域**。新增 CLI 命令 / API 端点按语义归位——PR 相关入 `pr`、评审 Agent 入 `agent`;与具体 PR / Agent 无关的**系统性 / 会话级**操作(whoami / version 等)置于**根层级**,不套领域组。归属看**语义而非是否 PR 维度**(如 `categories` / `refresh` 无 `--pr` 仍属 `pr`——它们服务于 PR 列表)。完整理由见 [CLI 设计](docs/arch/04-integration/02-cli.md)。 +- **文档对齐**:CLI 改动(命令树 / 写边界 / 输出契约 / 连接配置)须同步三类文档,缺一即漂移——① **arch 设计**:[docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md)(命令树与边界),涉及 API 端点再改 [01-service-api.md](docs/arch/04-integration/01-service-api.md)(端点表);② **guide 用法**:[docs/guide/06-cli.md](docs/guide/06-cli.md);③ **skill 交付**:`cli/SKILL.md` + `cli/README.md`(随压缩包投放为 agent skill 的主形态)。新增 / 改命令务必三类齐更。 ## 约定 @@ -96,7 +102,11 @@ tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag( ## 国际化 (i18n) -GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**,无源/译层级;UI 语言由 `config.language` 经 `resolveLanguage` 决定,空则按 OS 回落英语)。**默认 / 兜底语言取 `en-US`**(国际化标准:缺 key 回退英文而非中文):渲染层 en-US 静态打包进入口 + 其余懒加载、`fallbackLng: 'en-US'`,主进程各持一份 locale、同样兜底 en-US。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩的:①新增文本须在**各语言 locale 都加**并保持**递归字典序**(日语复数同中文仅 `_other`、德语同英语需 `_one`/`_other`);②i18next **只有 `count`** 触发复数,普通计数插值要换别的变量名;③**不要开 `nonExplicitSupportedLngs`**——它把 `zh-CN` 按基码 `zh` 查找、与按 `zh-CN` 注册的 bundle 错位 → 整页裸 key。 +GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**;**默认 / 兜底 `en-US`**,缺 key 回退英文)。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩(详见该篇): + +1. 新增文本各语言 locale 都加且保持**递归字典序**; +2. 复数只认 `count`(普通计数插值换别名); +3. 勿开 `nonExplicitSupportedLngs`(按基码 `zh` 错位 → 整页裸 key)。 ## 文档约定 @@ -107,14 +117,12 @@ GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / ` ## 工程维护坑 -- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外,**必须**:① 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`;② 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。漏 ② 时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 -- **嵌入式 pr-agent 运行时**(见 [docs/arch/02-agent/03-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)):`apps/desktop/scripts/assemble-pragent-runtime.mjs` 按 `pragent-runtime.json` 把可重定位 CPython + pinned pr-agent 装到 `apps/desktop/vendor/pragent/`(gitignored)。 -- **monkeypatch shim** `apps/desktop/scripts/pragent-shim/`(薄加载器 `sitecustomize.py` + 领域拆分包 `meebox_pragent_shim/`:`patches/` 各 patch + `cli/` 本地 CLI provider + `runtime.py`/`usage.py`。对 pr-agent 的无侵入补丁): - - 改了它,跑一次 `npm --prefix apps/desktop run prepare:pragent` 即重新同步进 vendor(幂等跳过分支也会同步 shim),**无需 `--force` 全量重建**。 - - 受版本守卫:`meebox_pragent_shim/runtime.py` 的 `_EXPECTED_PRAGENT_VERSION` 必须等于 `pragent-runtime.json` 的 `prAgent.version`(assemble 构建期强校验,运行期不符则跳过补丁 + stderr WARNING)。升级 pr-agent 要同步两处并重新验证。 - - **拆分铁律**:各 patch 对 `pr_agent` 的 import 一律放在 patch 函数体内(惰性);模块顶层只 import 同包内的 runtime/usage 等,**绝不在顶层 import pr_agent**(否则 sitecustomize 阶段即 eager 加载,拖慢每次 python 启动)。 - - 调试:`MEEBOX_SHIM_DEBUG=1` 让 shim 打 stderr 调试。 +- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外**必须**: + 1. 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`; + 2. 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。 + + 漏第 2 步时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 +- **pr-agent 运行时 / shim**:嵌入式 CPython + pinned pr-agent 由 `assemble-pragent-runtime.mjs` 装到 `vendor/pragent`(gitignored);对 pr-agent 的无侵入补丁在 `scripts/pragent-shim/`。机制与铁律(惰性 import 拆分 · 版本守卫 · 改后跑 `prepare:pragent` 同步 · 调试 `MEEBOX_SHIM_DEBUG=1`)见 [02-agent/05-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)。弱网 pip 超时加 `PIP_DEFAULT_TIMEOUT=120`。 - **二进制资源走 Git LFS**(`*.png/.ico/.icns` 等):本地没装 git-lfs 时拿到的是指针文件,electron-builder 转图标会崩 → `brew install git-lfs && git lfs pull`。 - **dev 起不来**:若 `npm run dev` 报 `electron does not provide an export named …`,是环境里有 `ELECTRON_RUN_AS_NODE=1`(VSCode 扩展宿主会注入)→ `unset ELECTRON_RUN_AS_NODE` 再跑。 - **grep 个别文件无输出**:如 `repo-mirror-manager.ts` 被 `file` 判为 `data`(含非 UTF-8 字节),普通 grep 静默 → 用 `grep -a`。 -- **prepare:pragent 网络**:pip 默认 15s 超时,弱网下加 `PIP_DEFAULT_TIMEOUT=120` 或配国内镜像(`~/.config/pip/pip.conf`)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fac538..250ad45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.9.0-alpha.1] - 2026-07-02 + +> 本版重点: +> +> - **外部集成与 CLI**:开启本机 API 把 PR 浏览与评审 Agent 操作开放给外部集成,并提供跨平台命令行工具 `meebox` + +### ✨ 新增 + +- **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。 + - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 + - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 + - 开放浏览(当前身份 / PR 列表 / 详情 / diff / 动态 / 提交 / 评审人)、评审 Agent(状态 / 历史 / 自动评审 / 指令 / 对话 / 中断)与评审写动作(通过 / 需修改 / 发评论);不开放合并与变更类 Agent 工具(publish 等)。 +- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr `;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置。 +- **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 +- **「我创建的」PR 通知**:针对本人创建的 PR 新增三类系统通知——收到他人新评论、被评审标记「需修改」、出现合并冲突;通知分区提供独立开关、默认开启。 +- **命令回显气泡**:在评审 Agent 面板直接发起 `/review`、`/describe`、`/improve`、`/ask` 等命令时,命令即时以用户气泡回显在其结果卡片之上,贴合对话习惯;编排 / AutoPilot 派发的子任务不回显,避免与编排会话的用户消息重复。 +- **按提交发起评审 Agent**:在 Diff 视图将变更范围切到某个提交后,评审 Agent 面板直接键入的 `/describe`、`/review`、`/improve`、`/ask`(含自然语言提问)会自动限定在该提交自身改动(`parent..sha`)而非 PR 全量;输入栏以范围 chip 标示当前提交,chip 选中态源自视图选择,点击可临时禁用(不删除、切到别的提交自动复位),与 Diff 选区 chip 交互一致。同一时刻仅一个 scope 生效:选中 Diff 行时以选区为准、暂挂提交范围,取消选区后自动还原。运行中与完成的评审结果卡均展示所限定提交的范围徽标。一键自动评审仍作用于 PR 全量。 + +### ♻️ 变更 + +- 评审 Agent 的 `/ask`(本机 agentic CLI 提供方)注入代码检索指引:引导以只读搜索定向查符号 / 只读所需行段替代整文件通读与全仓扫描,在保留读真实文件深度的同时,降低探索轮次与 token 消耗。 +- 「追问数量」上限现同时约束自由对话 Agent(会话即委派)的自动 `/ask`:此前该上限仅作用于评审微流程的条件追问,自由规划循环仅受「Agent 最大步数」约束、可连续多次 `/ask`(每次均为一次成本较高的 agentic 探索);现自由对话中的 `/ask` 亦按「追问数量」封顶(始终生效,与「自动追问」开关无关),避免探索成本失控。 +- 状态栏不再常态显示 pr-agent 版本号(减少常态噪声),仅在其不可用时保留告警提示;版本号改在设置「关于」页的运行环境信息中展示。 +- 「我创建的」分类下的「待处理」筛选并入存在合并冲突的 PR:作者视角下有冲突的 PR 需其跟进解决(即便评审已通过),故一并计入待处理。 +- PR 列表的状态二级筛选改为宽度自适应胶囊:一行容得下即不换行,容纳不下时换行并按每行项数均分撑满整行,消除换行后右侧留白参差。 +- 评审 Agent 派生的临时工作树内部分支不再使用固定的品牌化名称,改为与 PR 关联的 `pr-/head`、`pr-/base`(分层命名贴合平台 PR 引用惯例):消除产出中可辨识的固定行为特征,同时保证该内部分支名不会漏入对外发布的评审内容。 + +### 🔧 修复 + +- 修复 PR 列表分组标题背景色、Windows 窗口右上角控制按钮此前不随主题(编辑器配色主题)变化的问题;现二者均跟随当前主题派生配色,深浅 / 主题切换实时生效。 +- 修复点开带「@我 / 回复我」未读计数的 PR 后,标题处的未读计数 chip 不立即消除、需等下一轮轮询才清零的问题:标为已读时同步乐观清零该计数(此前仅清未读圆点,遗漏了计数 chip)。 + ## [0.8.0] - 2026-06-30 > 本版重点: @@ -371,6 +403,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +[0.9.0-alpha.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0-alpha.1 [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.5.0...v0.6.0 diff --git a/README.md b/README.md index e364c86a..d878cffd 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,13 @@ Code Meeseeks(内部开发代号 `meebox`)是命令行工具 [pr-agent](http - **多 LLM Provider** —— OpenAI / openai-compatible / DeepSeek / Anthropic / 通义千问 / 火山方舟等(本地 Ollama 经 openai-compatible 的 `/v1` 接入);也可通过本机已授权的本地 CLI 工具(如 claude / codex)调用第三方模型。 - **个性化规则** —— 每位 Reviewer 维护自己的规则目录(markdown + frontmatter,支持子目录递归组织),按项目 / 仓库 / 目标分支命中;命中的多条规则按 Ruleset 分段一并注入评审,`priority` 控制排序。 -- **运行参数可调** —— 评审任务并发、输入上下文长度、Agent 策略(自动追问开关、代码建议数量)均可在设置页调整。 +- **运行参数可调** —— 评审任务并发、输入上下文长度、Agent 策略(自动追问开关、追问 / 代码建议数量)均可在设置页调整。 + +#### 🔌 外部集成与 CLI + +- **可供外部 Agent 集成** —— PR 评审能力经本机 HTTP 接口 + 跨平台 CLI 对外开放,让本机 agentic 工具(如 claude / codex)、脚本、CI 把 PR 发现 / 浏览 / 评审 Agent 操作纳入自动化流程。 +- **本地 API 服务** —— 可选开启一个本机 API,将 PR 发现 / 浏览 / diff / 评审 Agent 操作 / 评审写动作以语言无关的 HTTP 契约开放出来;默认仅本机可达、强制访问令牌鉴权,不开放合并与变更类工具。 +- **跨平台命令行工具 `meebox`** —— 随发布提供 Windows / macOS / Linux 客户端,经本地 API 浏览 PR、驱动评审 Agent 并执行评审写动作(approve / needswork / comment);`meebox login` 一次存好凭据即可免传参,**压缩包即 agent skill 目录、可直接投放到 agent 的 skills 目录**。用法见 **[CLI 命令行工具](docs/guide/06-cli.md)**。 #### 🎨 界面与体验 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 976d3a59..8158b1d2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.8.0", + "version": "0.9.0-alpha.1", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/apps/desktop/src/main/bootstrap/window-manager.ts b/apps/desktop/src/main/bootstrap/window-manager.ts index 75671fa2..1d6d2210 100644 --- a/apps/desktop/src/main/bootstrap/window-manager.ts +++ b/apps/desktop/src/main/bootstrap/window-manager.ts @@ -24,10 +24,31 @@ const TITLE_BAR_OVERLAY = { dark: { color: '#1e1e1e', symbolColor: '#cccccc' }, light: { color: '#f8f8f8', symbolColor: '#1f1f20' }, }; -/** 按当前有效主题取窗控按钮配色(含高度,供建窗与 setTitleBarOverlay 共用)。 */ -function overlayOptions(): { color: string; symbolColor: string; height: number } { - const c = nativeTheme.shouldUseDarkColors ? TITLE_BAR_OVERLAY.dark : TITLE_BAR_OVERLAY.light; - return { ...c, height: 36 }; +// 渲染层派生的窗控配色(跟随具体主题的 --bg-app/--text-primary,hex);null 时按 nativeTheme 深浅取通用色兜底。 +let overlayColors: { color: string; symbolColor: string } | null = null; +/** 通用深/浅窗控配色(无渲染层派生色时的兜底,按 nativeTheme 有效深浅)。 */ +function genericOverlayColors(): { color: string; symbolColor: string } { + return nativeTheme.shouldUseDarkColors ? TITLE_BAR_OVERLAY.dark : TITLE_BAR_OVERLAY.light; +} +/** 当前窗控 overlay(渲染层派生色优先,否则通用色)+ 高度(与 .app-titlebar 一致 36px)。 */ +function currentOverlay(): { color: string; symbolColor: string; height: number } { + return { ...(overlayColors ?? genericOverlayColors()), height: 36 }; +} +/** + * 由渲染层在主题应用后经 IPC 调用:把当前主题派生的窗控配色(color=--bg-app、symbolColor=--text-primary) + * 设给所有窗口;传 null 回退通用深/浅色。使窗控按钮与具体主题的标题栏底色精确同色,而非仅通用深/浅。 + */ +export function setWindowControlColors(colors: { color: string; symbolColor: string } | null): void { + overlayColors = colors; + if (process.platform === 'darwin') return; // macOS 无 titleBarOverlay + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + try { + win.setTitleBarOverlay(currentOverlay()); + } catch { + /* 平台不支持 setTitleBarOverlay → 忽略 */ + } + } } /** @@ -89,7 +110,7 @@ export class WindowManager { titleBarStyle: 'hidden', ...(process.platform === 'darwin' ? { trafficLightPosition: { x: 12, y: 11 } } - : { titleBarOverlay: overlayOptions() }), + : { titleBarOverlay: currentOverlay() }), // dev 下显式给窗口图标;打包态窗口/任务栏图标走 exe 内嵌(electron-builder),故仅 dev 设置。 icon: app.isPackaged ? undefined @@ -149,13 +170,14 @@ export class WindowManager { ); }); - // 全局主题切换(config:setEditorAppearance 据主题改 nativeTheme.themeSource)或 'auto' 主题下 OS - // 深浅变化时,nativeTheme 发 'updated':重置 Windows 窗控按钮配色跟随主题(macOS 无 titleBarOverlay,不注册)。 + // 'auto' 主题下 OS 深浅变化时 nativeTheme 发 'updated':按当前 overlay(渲染层派生色优先,否则通用色) + // 重置窗控配色兜底(macOS 无 titleBarOverlay,不注册)。具体主题的精确配色由渲染层经 setWindowControlColors + // 主动推送(见 useGlobalTheme),此处仅在渲染层未推送时按 nativeTheme 深浅回退。 if (process.platform !== 'darwin') { const onThemeUpdated = (): void => { if (win.isDestroyed()) return; try { - win.setTitleBarOverlay(overlayOptions()); + win.setTitleBarOverlay(currentOverlay()); } catch { /* 平台不支持 setTitleBarOverlay → 忽略 */ } diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts index c0a0ed71..0cf24d49 100644 --- a/apps/desktop/src/main/controllers/agent.ts +++ b/apps/desktop/src/main/controllers/agent.ts @@ -142,6 +142,7 @@ export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { 'user', req.referencedContext, req.referencedFinding, + req.scope, ); }; diff --git a/apps/desktop/src/main/controllers/app.ts b/apps/desktop/src/main/controllers/app.ts index 59938dab..7ef49dc9 100644 --- a/apps/desktop/src/main/controllers/app.ts +++ b/apps/desktop/src/main/controllers/app.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { app, BrowserWindow, dialog, shell } from 'electron'; import type { Logger } from 'pino'; +import { setWindowControlColors as applyWindowControlColors } from '../bootstrap/window-manager.js'; import { buildAppInfo, buildConnectionSummaries } from '../services/app.js'; import { getContext } from '../services/context.js'; import { applyBadgeCount } from '../services/notifications.js'; @@ -25,6 +26,13 @@ export const readAppInfo: IpcController<'app:info'> = () => buildAppInfo(getCont */ export const readAppPaths: IpcController<'app:paths'> = () => getContext().bootstrap.paths; +/** + * 渲染层在主题应用后推送窗控按钮配色(跟随具体主题的 --bg-app/--text-primary);null 回退通用深/浅。 + */ +export const setWindowControlColors: IpcController<'window:setControlColors'> = (_event, req) => { + applyWindowControlColors(req); +}; + /** * pr-agent 探测状态(是否就绪)。 */ diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index fd400a97..5d555803 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { nativeTheme } from 'electron'; import { editorThemeNativeSource } from '@meebox/shared'; import { writeConfig } from '@meebox/config'; @@ -187,6 +188,31 @@ export const testConnection: IpcController<'config:testConnection'> = async (_ev } }; +/** + * 写本地 API 服务监听配置(开关 / host / port / token);内存同步后热重建监听器(停旧起新)。 + * token 由请求体携带(设置页保存当前值);单独「重新生成 token」走 generateServiceToken。 + */ +export const setService: IpcController<'config:setService'> = async (_event, req) => { + const { bootstrap, logger, reconfigureApiServer } = getContext(); + const next = { ...bootstrap.config, service: req.service }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.service = req.service; + await reconfigureApiServer(); + logger.info( + { enabled: req.service.enabled, host: req.service.host, port: req.service.port }, + 'service listener config updated (hot-reloaded)', + ); +}; + +/** + * 生成一枚高强度随机 bearer token(32 字节 → base64url,43 字符,字符集 [A-Za-z0-9-_],URL / 请求头安全) + * 并返回,**不落盘**——由前端置入设置草稿,随底栏「保存」经 config:setService 生效;不保存则丢弃 + * (与 host / port 同为草稿制)。 + */ +export const generateServiceToken: IpcController<'config:generateServiceToken'> = () => { + return { token: randomBytes(32).toString('base64url') }; +}; + /** * 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 */ diff --git a/apps/desktop/src/main/i18n/locales/de-DE.json b/apps/desktop/src/main/i18n/locales/de-DE.json index 13c6cb02..c1d260e2 100644 --- a/apps/desktop/src/main/i18n/locales/de-DE.json +++ b/apps/desktop/src/main/i18n/locales/de-DE.json @@ -21,6 +21,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "Neuer Kommentar zu deinem PR" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "Dein PR hat einen Merge-Konflikt" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "Dein PR wurde als überarbeitungsbedürftig markiert" + }, "mention": { "body": "#{{id}} {{title}}", "title": "Du wurdest erwähnt" diff --git a/apps/desktop/src/main/i18n/locales/en-US.json b/apps/desktop/src/main/i18n/locales/en-US.json index 98f5cfbb..4c682615 100644 --- a/apps/desktop/src/main/i18n/locales/en-US.json +++ b/apps/desktop/src/main/i18n/locales/en-US.json @@ -21,6 +21,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "New comment on your PR" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "Your PR has a merge conflict" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "Your PR was marked needs-work" + }, "mention": { "body": "#{{id}} {{title}}", "title": "You were mentioned" diff --git a/apps/desktop/src/main/i18n/locales/ja-JP.json b/apps/desktop/src/main/i18n/locales/ja-JP.json index 5a9caca4..f6c867ff 100644 --- a/apps/desktop/src/main/i18n/locales/ja-JP.json +++ b/apps/desktop/src/main/i18n/locales/ja-JP.json @@ -20,6 +20,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "あなたの PR に新しいコメント" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "あなたの PR にマージ競合が発生" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "あなたの PR が要修正に設定されました" + }, "mention": { "body": "#{{id}} {{title}}", "title": "あなたへのメンションがあります" diff --git a/apps/desktop/src/main/i18n/locales/zh-CN.json b/apps/desktop/src/main/i18n/locales/zh-CN.json index 09308a59..dc1c905f 100644 --- a/apps/desktop/src/main/i18n/locales/zh-CN.json +++ b/apps/desktop/src/main/i18n/locales/zh-CN.json @@ -20,6 +20,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "你的 PR 有新评论" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "你的 PR 出现合并冲突" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "你的 PR 被标记需修改" + }, "mention": { "body": "#{{id}} {{title}}", "title": "有人提到了你" diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cbb9c078..4d140825 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -21,6 +21,7 @@ import { } from './bootstrap/index.js'; import { initMainI18n } from './i18n/index.js'; import { registerIpcHandlers } from './ipc.js'; +import { ApiServer } from './services/api-server/index.js'; import { readConnectionStates } from './utils/connection-state.js'; // 进程(模块加载)起点:用于度量到主窗口首帧(ready-to-show)的启动耗时。 @@ -53,6 +54,8 @@ class App { private conns!: ConnectionRuntimeController; private windowManager!: WindowManager; private ipcControl?: IpcControl; + /** 本地 API 服务监听器(默认关闭;按 config.service 决定是否 listen)。 */ + private apiServer?: ApiServer; private quitCleanupDone = false; constructor(private readonly startMs: number) {} @@ -210,7 +213,15 @@ class App { connectionRuntime: this.conns.runtime, reconfigureConnections: () => this.conns.reconfigure(), repoMirror: this.repoMirror, + // 惰性引用:ApiServer 在 registerIpcHandlers 之后才构造(其请求处理依赖此刻才安装的 + // ControllerContext 单例);闭包在 config:setService 调用时才取最新实例。 + reconfigureApiServer: () => this.apiServer?.reconfigure() ?? Promise.resolve(), }); + + // 本地 API 服务监听器:ControllerContext 已由 registerIpcHandlers 安装,可安全处理请求。 + // 按 config.service 决定是否实际 listen(默认关闭);监听失败为非致命(内部已兜底记录)。 + this.apiServer = new ApiServer({ bootstrap: this.bootstrap, logger: this.logger }); + await this.apiServer.start(); } /** @@ -293,6 +304,8 @@ class App { // 不清理会留孤儿进程锁住安装目录 → 升级时 NSIS 报「应用无法关闭」。 app.on('before-quit', (event) => { if (this.poller) this.poller.stop(); + // 停本地 API 监听(停止接收新连接);fire-and-forget,关闭很快、不阻塞退出。 + void this.apiServer?.stop(); if (this.quitCleanupDone) return; const aborted = this.ipcControl?.abortAllActiveRuns() ?? 0; if (aborted === 0) return; // 无进行中 run,直接退出 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index c86338f1..25cb790a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -44,6 +44,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('app:paths', app.readAppPaths); // 关键目录路径(config / agent / 日志) ipcMain.handle('app:prAgentStatus', app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) ipcMain.handle('log:write', app.writeRendererLog); // 渲染层日志回传落盘 + ipcMain.handle('window:setControlColors', app.setWindowControlColors); // 渲染层推送主题派生窗控配色 ipcMain.handle('app:connections', app.listConnections); // 当前活动连接摘要(Header / 状态栏) ipcMain.handle('app:userAvatar', app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) ipcMain.handle('app:openConfigFile', app.openConfigFile); // 打开 config.yaml @@ -114,6 +115,8 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) ipcMain.handle('config:setPoller', config.setPoller); // 设轮询间隔(热替换定时器) ipcMain.handle('config:setMaxConcurrency', config.setMaxConcurrency); // 设评审并发数(热替换队列上限) + ipcMain.handle('config:setService', config.setService); // 设本地 API 服务监听(热重建监听器) + ipcMain.handle('config:generateServiceToken', config.generateServiceToken); // 重新生成 API bearer token /* * Agent 交互 diff --git a/apps/desktop/src/main/services/agent/flows/planning.ts b/apps/desktop/src/main/services/agent/flows/planning.ts index 82c77b21..da8ded62 100644 --- a/apps/desktop/src/main/services/agent/flows/planning.ts +++ b/apps/desktop/src/main/services/agent/flows/planning.ts @@ -84,6 +84,9 @@ export async function runPlanningForPr( matchedRuleInstructions: combineRuleInstructions(matchedRules), language: getMainLanguage(), maxSteps: agentCfg.max_steps, + // /ask 预算:自由规划里连续 /ask 各为一次 agentic 探索、成本高,按配置「追问数量」封顶(与「自动追问」 + // 开关无关——开关仅约束评审微流程;此处始终生效,遵循配置的追问数量上限)。 + maxFollowupAsks: agentCfg.strategy.max_followup_asks, signal, onStep: (sessionId, step) => runtime.emitStep(pr, sessionId, step), // 中途输入转向:planner 每轮取出排队消息时在此落盘进会话 + 广播刷新(即时显示为用户气泡), diff --git a/apps/desktop/src/main/services/agent/planning.ts b/apps/desktop/src/main/services/agent/planning.ts index 510e7aff..1a241dd4 100644 --- a/apps/desktop/src/main/services/agent/planning.ts +++ b/apps/desktop/src/main/services/agent/planning.ts @@ -85,6 +85,8 @@ export interface PlanningDeps { matchedRuleInstructions?: string | null; language: string; maxSteps: number; + /** 本会话 /ask 数量上限(配置「追问数量」max_followup_asks):连续 agentic 探索成本高,按此封顶。 */ + maxFollowupAsks: number; /** 用户选中的代码引用(隐式上下文):注入规划 LLM 当轮提示,不进持久化用户消息。 */ referencedContext?: string; signal?: AbortSignal; @@ -155,6 +157,7 @@ export async function runPlanning( history, referencedContext: deps.referencedContext, maxSteps: deps.maxSteps, + maxFollowupAsks: deps.maxFollowupAsks, }, ); diff --git a/apps/desktop/src/main/services/api-server/compat.ts b/apps/desktop/src/main/services/api-server/compat.ts new file mode 100644 index 00000000..25c91428 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/compat.ts @@ -0,0 +1,36 @@ +import { coerce as semverCoerce, lt as semverLt, valid as semverValid } from 'semver'; + +/** + * CLI ↔ server 兼容性门控(见 docs/arch/04-integration/01-service-api.md)。 + * + * CLI 每个请求带上自身版本头,服务端据集中管理的**最低可兼容版本**统一拦截过旧的 CLI——对所有 API 调用 + * 一视同仁,不做按端点的差异化兼容。默认宽松:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地 + * `dev` 构建)均放行,保证既有 CLI 默认可用;仅当版本头**可解析且低于下限**时才门控。 + * + * **按版本线(major.minor.patch)比对,忽略预发布后缀**:CLI 与 app 同源发版,预发布构建(如 + * `0.9.0-alpha.1`)与其正式版属同一版本线、共享同一线协议。semver 里预发布**低于**其正式版 + * (`0.9.0-alpha.1` < `0.9.0`),若直接比对会把与下限同线的预发布 CLI 误判为过旧(下限 `0.9.0` 时连 + * `0.9.0-alpha.1` 都被拦)。故先把版本 coerce 到 `major.minor.patch` 再比——下限是版本线粒度的 + * 破坏性变更闸门,不区分同线内的预发布序号。 + */ + +/** CLI 在此请求头声明自身版本(Node 会小写化头名)。与 CLI 端手写常量对齐(无代码级共享)。 */ +export const CLI_VERSION_HEADER = 'x-meebox-cli-version'; + +/** + * 服务端可兼容的最低 CLI 版本。破坏性线协议变更时上调此值,即门控掉更旧的 CLI。 + * 取当前 CLI 首发版本为下限(此前无更旧的已发布 CLI),默认不拦截任何在用版本。 + */ +export const MIN_CLI_VERSION = '0.9.0'; + +/** 请求携带的 CLI 版本是否过旧(应拦截)。缺头 / 不可解析 → false(放行)。 */ +export function isClientTooOld(rawHeader: string | string[] | undefined): boolean { + const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + if (!raw) return false; + const v = semverValid(raw.trim()); + if (!v) return false; // 不可解析(dev 等)→ 放行 + // 剥离预发布后缀,按版本线(major.minor.patch)比对,使同线预发布不低于下限(见文件头注释)。 + const line = semverCoerce(v); + if (!line) return false; + return semverLt(line, MIN_CLI_VERSION); +} diff --git a/apps/desktop/src/main/services/api-server/http.ts b/apps/desktop/src/main/services/api-server/http.ts new file mode 100644 index 00000000..c40737bb --- /dev/null +++ b/apps/desktop/src/main/services/api-server/http.ts @@ -0,0 +1,84 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { AppError, ERROR_CODES, type AppErrorMeta, type ErrorCode } from '@meebox/shared'; + +/** + * 本地 API 的 HTTP 工具:统一响应封套({ ok, data } / { ok:false, error })、请求体读取、 + * 错误 → HTTP 状态码映射。见 docs/arch/04-integration/01-service-api.md。 + */ + +const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB 请求体上限 + +/** API 层错误(鉴权 / 路由 / 校验 / 写禁止等):自带 HTTP 状态码与错误码。 */ +export class HttpError extends Error { + constructor( + readonly status: number, + readonly code: ErrorCode, + readonly meta?: AppErrorMeta, + ) { + super(code); + this.name = 'HttpError'; + } +} + +function writeJson(res: ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(body); +} + +/** 成功响应:200 + { ok:true, data }。 */ +export function sendOk(res: ServerResponse, data: unknown): void { + writeJson(res, 200, { ok: true, data: data ?? null }); +} + +/** 失败响应:按错误映射状态码 + { ok:false, error:{ code, meta } };返回所选状态码 / 码供日志。 */ +export function sendError(res: ServerResponse, err: unknown): { status: number; code: string } { + const mapped = mapError(err); + writeJson(res, mapped.status, { + ok: false, + error: { code: mapped.code, ...(mapped.meta ? { meta: mapped.meta } : {}) }, + }); + return { status: mapped.status, code: mapped.code }; +} + +function mapError(err: unknown): { status: number; code: ErrorCode; meta?: AppErrorMeta } { + if (err instanceof HttpError) return { status: err.status, code: err.code, meta: err.meta }; + if (err instanceof AppError) return { status: statusForAppCode(err.code), code: err.code, meta: err.meta }; + return { status: 500, code: ERROR_CODES.SV_UNCLASSIFIED }; +} + +/** 把控制器抛出的 AppError 业务码映射到合适的 HTTP 状态码(未覆盖者归 500)。 */ +function statusForAppCode(code: ErrorCode): number { + switch (code) { + case ERROR_CODES.PR_NOT_FOUND: + return 404; + case ERROR_CODES.PR_FORBIDDEN: + return 403; + case ERROR_CODES.PR_URL_INVALID: + case ERROR_CODES.AG_ASK_NEEDS_QUESTION: + return 400; + case ERROR_CODES.PR_NO_ACTIVE_CONNECTION: + return 409; + case ERROR_CODES.AG_PR_AGENT_NOT_READY: + return 503; + default: + return 500; + } +} + +/** 读取并解析 JSON 请求体(空体 → undefined);超限 413、非法 JSON 400,均归一为 SV 错误码。 */ +export async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + total += (chunk as Buffer).length; + if (total > MAX_BODY_BYTES) throw new HttpError(413, ERROR_CODES.SV_BAD_REQUEST, { reason: 'body too large' }); + chunks.push(chunk as Buffer); + } + if (total === 0) return undefined; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'invalid json' }); + } +} diff --git a/apps/desktop/src/main/services/api-server/index.ts b/apps/desktop/src/main/services/api-server/index.ts new file mode 100644 index 00000000..fe586550 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/index.ts @@ -0,0 +1 @@ +export { ApiServer, type ApiServerDeps } from './server.js'; diff --git a/apps/desktop/src/main/services/api-server/routes/agent.ts b/apps/desktop/src/main/services/api-server/routes/agent.ts new file mode 100644 index 00000000..dcfb8848 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/agent.ts @@ -0,0 +1,85 @@ +import { ERROR_CODES, type ReviewRunTool } from '@meebox/shared'; +import * as agentCtl from '../../../controllers/agent.js'; +import { HttpError } from '../http.js'; +import { toPrAgentRuns } from '../views.js'; +import { NO_EVENT, seg, type Route, type RouteHandler } from './shared.js'; + +/** + * 评审 Agent 领域端点:状态 / 会话(浏览),auto review / 指令 / 聊天 / 中断(写入型,复用既有 run 队列), + * 以及按 run 的发现与取消。Agent `instruct` **仅只读工具**,变更类工具(publish 等)在 API 层硬拒绝。 + */ + +/** API 仅允许的只读 Agent 指令(与工具注册表 isRun 只读族一致;写工具不在此列)。 */ +const READ_ONLY_TOOLS: ReadonlySet = new Set([ + 'describe', + 'review', + 'ask', + 'improve', +]); + +const agentStatus: RouteHandler = ({ params }) => + agentCtl.getSession(NO_EVENT, { localId: params.id }); + +const agentHistory: RouteHandler = ({ params }) => + agentCtl.getConversation(NO_EVENT, { localId: params.id }); + +const agentReview: RouteHandler = ({ params }) => agentCtl.runReview(NO_EVENT, { localId: params.id }); + +/** 发送只读 Agent 指令(describe / review / ask / improve);写工具硬拒绝(403),无二次确认。 */ +const agentInstruct: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { command?: string; args?: string }; + const command = (b.command ?? '').replace(/^\//, '') as ReviewRunTool; + if (!READ_ONLY_TOOLS.has(command)) { + throw new HttpError(403, ERROR_CODES.SV_WRITE_NOT_ALLOWED, { command: b.command ?? '' }); + } + if (command === 'ask' && !b.args?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'ask requires args' }); + } + return agentCtl.runPragent(NO_EVENT, { localId: params.id, tool: command, question: b.args }); +}; + +/** 发送自然语言聊天(可触发 Agent 任务):运行中入队、否则起一轮自由规划兜底。 */ +const agentChat: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { message?: string }; + if (!b.message?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'message required' }); + } + return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); +}; + +/** 中断该 PR 正在运行的 Agent(思考 / 执行任意阶段即时停)。PR 级停,非按单个工具 run。 */ +const agentStop: RouteHandler = ({ params }) => + agentCtl.stopAgent(NO_EVENT, { localId: params.id }); + +/** 该 PR 在运行队列里的 pr-agent runs(active + waiting),供按 run 取消前的发现。 */ +const agentRuns: RouteHandler = async ({ params }) => { + const snapshot = await agentCtl.getQueue(NO_EVENT, undefined); + return toPrAgentRuns(snapshot, params.id); +}; + +/** 取消该 PR 的某个 pr-agent run(active SIGKILL / waiting 出队)。先校验 run 归属该 PR。 */ +const agentRunCancel: RouteHandler = async ({ params }) => { + const snapshot = await agentCtl.getQueue(NO_EVENT, undefined); + const belongs = [...snapshot.active, ...snapshot.waiting].some( + (r) => r.runId === params.runId && r.prLocalId === params.id, + ); + if (!belongs) { + throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND, { runId: params.runId, localId: params.id }); + } + return agentCtl.cancelPragent(NO_EVENT, { runId: params.runId }); +}; + +export const agentRoutes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/prs/:id/agent'), handler: agentStatus }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent/conversation'), handler: agentHistory }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/stop'), handler: agentStop }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent/runs'), handler: agentRuns }, + { + method: 'POST', + segments: seg('/api/v1/prs/:id/agent/runs/:runId/cancel'), + handler: agentRunCancel, + }, +]; diff --git a/apps/desktop/src/main/services/api-server/routes/index.ts b/apps/desktop/src/main/services/api-server/routes/index.ts new file mode 100644 index 00000000..45293411 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/index.ts @@ -0,0 +1,36 @@ +import { agentRoutes } from './agent.js'; +import { prRoutes } from './pr.js'; +import { seg, type Route } from './shared.js'; +import { systemRoutes } from './system.js'; + +/** + * 本地 API 的路由**聚合注册 + 匹配**。各业务领域的处理器分置于同目录的 system / pr / agent 模块 + * (均复用 IPC controller 同源逻辑);本文件只做注册与路径匹配,不含业务逻辑。 + * 端点全表与写边界见 docs/arch/04-integration/01-service-api.md。 + */ +export const routes: Route[] = [...systemRoutes, ...prRoutes, ...agentRoutes]; + +export type { Route, RouteContext, RouteHandler } from './shared.js'; + +/** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ +export function matchRoute( + method: string, + pathname: string, +): { route: Route; params: Record } | null { + const parts = seg(pathname); + for (const route of routes) { + if (route.method !== method || route.segments.length !== parts.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < route.segments.length; i++) { + const s = route.segments[i]; + if (s.startsWith(':')) params[s.slice(1)] = decodeURIComponent(parts[i]); + else if (s !== parts[i]) { + ok = false; + break; + } + } + if (ok) return { route, params }; + } + return null; +} diff --git a/apps/desktop/src/main/services/api-server/routes/pr.ts b/apps/desktop/src/main/services/api-server/routes/pr.ts new file mode 100644 index 00000000..60666364 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/pr.ts @@ -0,0 +1,116 @@ +import type { DiffSide } from '@meebox/ipc'; +import { + ERROR_CODES, + PR_SECONDARY_FILTERS, + filterPullRequests, + type PrDiscoveryFilter, + type PrSecondaryFilter, +} from '@meebox/shared'; +import * as prCtl from '../../../controllers/pr.js'; +import { getContext } from '../../context.js'; +import { HttpError } from '../http.js'; +import { toPrListItem } from '../views.js'; +import { NO_EVENT, seg, type Route, type RouteHandler } from './shared.js'; + +/** + * PR 领域端点:列表 / 详情 / diff / 动态 / 提交 / 评审人(浏览),刷新(refresh)与分类词表(categories), + * 以及评审写动作(approve / needswork / comment,真实远端写,复用 GUI 同源 controller)。 + * 仍**不**暴露 merge(合并)。写边界见 docs/arch/04-integration/01-service-api.md。 + */ + +/** 列表分页默认页大小(`limit` 缺省 / 非法 / ≤0 时取此值)。 */ +const DEFAULT_LIMIT = 100; + +/** 当前启用平台下可用的分类标签:`categories`(平台发现分类)+ `statuses`(状态 / 合并态筛选)。 */ +const categories: RouteHandler = () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const built = activeId + ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) + : undefined; + const caps = built?.adapter.connection.capabilities(); + const categoryList: PrDiscoveryFilter[] = caps?.discoveryFilters + ? [...caps.discoveryFilters] + : ['review-requested']; + return { + platform: built?.adapter.kind ?? null, + categories: categoryList, + statuses: [...PR_SECONDARY_FILTERS], + }; +}; + +/** + * 触发一次立即轮询刷新(等价 GUI 的手动刷新 / 窗口聚焦刷新):拉取所有连接的最新 PR、落本地, + * 返回本轮计数汇总(fetched / changed / added / removed / errors)。复用 GUI 同源 poller.tick + * (`prs:refresh`)。无远端写副作用(纯读远端 + 落本地),列为安全的开放动作。 + */ +const refresh: RouteHandler = () => prCtl.refreshPrs(NO_EVENT, undefined); + +/** + * PR 列表:`category`(一级发现分类)+ `status`(二级状态 / 合并态)过滤 + `q` 检索 + + * `skip`/`limit` 分页(默认 limit 100)。过滤语义复用 @meebox/shared 的纯谓词(与渲染层侧栏同源); + * 返回**精简列表投影**({@link toPrListItem},去 description 明细、人员仅 slug),此处仅解析参数 + 委派。 + */ +const listPrs: RouteHandler = async ({ query }) => { + const all = await prCtl.listPrs(NO_EVENT, undefined); + const filtered = filterPullRequests(all, { + primary: (query.get('category') as PrDiscoveryFilter) || undefined, + secondary: (query.get('status') as PrSecondaryFilter) || undefined, + query: query.get('q') ?? undefined, + }); + const skip = Math.max(0, Number.parseInt(query.get('skip') ?? '', 10) || 0); + const limitRaw = Number.parseInt(query.get('limit') ?? '', 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : DEFAULT_LIMIT; + return filtered.slice(skip, skip + limit).map(toPrListItem); +}; + +const showPr: RouteHandler = ({ params }) => getContext().pr.findPrOrThrow(params.id); + +const reviewers: RouteHandler = async ({ params }) => + (await getContext().pr.findPrOrThrow(params.id)).reviewers; + +/** 无 path → 变更文件列表;带 path → 取该文件某一侧(默认 head)内容。 */ +const diff: RouteHandler = ({ params, query }) => { + const path = query.get('path'); + if (path) { + const side: DiffSide = query.get('side') === 'base' ? 'base' : 'head'; + return prCtl.getFileContent(NO_EVENT, { localId: params.id, side, path }); + } + return prCtl.listChangedFiles(NO_EVENT, { localId: params.id }); +}; + +const activity: RouteHandler = ({ params }) => + prCtl.listActivity(NO_EVENT, { localId: params.id }); + +const commits: RouteHandler = ({ params }) => prCtl.listCommits(NO_EVENT, { localId: params.id }); + +/** 评审决断「通过」:先写远端评审状态、再落本地(复用 GUI 同源 setPrStatus)。 */ +const approve: RouteHandler = ({ params }) => + prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'approved' }); + +/** 评审决断「需修改」:先写远端评审状态、再落本地。 */ +const needswork: RouteHandler = ({ params }) => + prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'needs_work' }); + +/** 发一条顶层(不锚文件)评论到远端 PR。body.body 为评论正文,空则 400。 */ +const comment: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { body?: string }; + if (!b.body?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'comment body required' }); + } + return prCtl.createComment(NO_EVENT, { localId: params.id, body: b.body }); +}; + +export const prRoutes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/categories'), handler: categories }, + { method: 'POST', segments: seg('/api/v1/refresh'), handler: refresh }, + { method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs }, + { method: 'GET', segments: seg('/api/v1/prs/:id'), handler: showPr }, + { method: 'GET', segments: seg('/api/v1/prs/:id/diff'), handler: diff }, + { method: 'GET', segments: seg('/api/v1/prs/:id/activity'), handler: activity }, + { method: 'GET', segments: seg('/api/v1/prs/:id/commits'), handler: commits }, + { method: 'GET', segments: seg('/api/v1/prs/:id/reviewers'), handler: reviewers }, + { method: 'POST', segments: seg('/api/v1/prs/:id/approve'), handler: approve }, + { method: 'POST', segments: seg('/api/v1/prs/:id/needswork'), handler: needswork }, + { method: 'POST', segments: seg('/api/v1/prs/:id/comment'), handler: comment }, +]; diff --git a/apps/desktop/src/main/services/api-server/routes/shared.ts b/apps/desktop/src/main/services/api-server/routes/shared.ts new file mode 100644 index 00000000..0e9c27a7 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/shared.ts @@ -0,0 +1,30 @@ +import type { IpcMainInvokeEvent } from 'electron'; + +/** + * 路由框架原语,供同目录各业务领域模块(system / pr / agent)与聚合器(index)共用。 + * 各域处理器**复用 IPC controller 同源逻辑**——controller 形态为 `(event, req)` 且这些路径不触碰 + * event,故以 {@link NO_EVENT} 占位调用,避免在 HTTP 侧另起一套实现。 + */ + +/** 单条路由处理器的入参:路径参数 / 查询串 / 已解析 body。 */ +export interface RouteContext { + params: Record; + query: URLSearchParams; + body: unknown; +} + +export type RouteHandler = (rc: RouteContext) => Promise | unknown; + +export interface Route { + method: 'GET' | 'POST'; + segments: string[]; + handler: RouteHandler; +} + +/** 把 `/api/v1/prs/:id` 切成非空段数组(注册与匹配共用)。 */ +export function seg(path: string): string[] { + return path.split('/').filter(Boolean); +} + +/** controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。 */ +export const NO_EVENT = undefined as unknown as IpcMainInvokeEvent; diff --git a/apps/desktop/src/main/services/api-server/routes/system.ts b/apps/desktop/src/main/services/api-server/routes/system.ts new file mode 100644 index 00000000..3296fe1d --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/system.ts @@ -0,0 +1,39 @@ +import { buildAppInfo } from '../../app.js'; +import { getContext } from '../../context.js'; +import { seg, type Route, type RouteHandler } from './shared.js'; + +/** + * 系统性 / 会话级端点:与具体 PR / Agent 无关的工具层信息——身份(whoami)与版本(version)。 + * 对应 CLI 的根层级系统性命令。 + */ + +/** + * 当前身份与集成平台:活动连接的 PAT 所属用户(name / displayName / slug)+ 平台种类 + + * 连接显示名。无活动连接时各项为 null。刻意收窄——不带 capabilities(那是 GUI 降级用的大对象)。 + */ +const whoami: RouteHandler = () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const built = activeId + ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) + : undefined; + if (!activeId || !built) { + return { platform: null, connectionId: null, displayName: null, user: null }; + } + const conn = ctx.bootstrap.config.connections.find((c) => c.id === activeId); + const user = built.adapter.connection.getCurrentUser(); + return { + platform: built.adapter.kind, + connectionId: activeId, + displayName: conn?.display_name ?? activeId, + user: user ? { name: user.name, displayName: user.displayName, slug: user.slug ?? null } : null, + }; +}; + +/** 服务端(桌面应用)版本,供 CLI `version` 同时展示客户端 + 服务端版本。 */ +const version: RouteHandler = () => ({ version: buildAppInfo(getContext().bootstrap).appVersion }); + +export const systemRoutes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/whoami'), handler: whoami }, + { method: 'GET', segments: seg('/api/v1/version'), handler: version }, +]; diff --git a/apps/desktop/src/main/services/api-server/server.ts b/apps/desktop/src/main/services/api-server/server.ts new file mode 100644 index 00000000..2437611f --- /dev/null +++ b/apps/desktop/src/main/services/api-server/server.ts @@ -0,0 +1,125 @@ +import { timingSafeEqual } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { BootstrapResult } from '@meebox/config'; +import { ERROR_CODES } from '@meebox/shared'; +import type { Logger } from 'pino'; +import { CLI_VERSION_HEADER, MIN_CLI_VERSION, isClientTooOld } from './compat.js'; +import { HttpError, readJsonBody, sendError, sendOk } from './http.js'; +import { matchRoute } from './routes/index.js'; + +/** + * 本地 API 服务监听器(见 docs/arch/04-integration/01-service-api.md)。 + * + * 主进程内置 HTTP listener,作为渲染层 IPC 之外的「第二前端」:复用同一 ControllerContext 与 service 层, + * 把只读 PR / Agent 能力暴露给外部 CLI / 工具。默认关闭;开启即强制 bearer token 鉴权。生命周期由 main 装配: + * start(按 config 决定是否 listen)/ stop(退出时优雅关闭)/ reconfigure(配置变更停旧起新)。 + */ +export interface ApiServerDeps { + bootstrap: BootstrapResult; + logger: Logger; +} + +export class ApiServer { + private server?: Server; + + constructor(private readonly deps: ApiServerDeps) {} + + /** 实时读内存 service 配置(token 变更无需重建即生效)。 */ + private get cfg() { + return this.deps.bootstrap.config.service; + } + + /** 按配置启动监听(未启用 / token 为空则不启动)。监听失败为非致命:记录后不抛,不拖垮应用启动。 */ + async start(): Promise { + if (this.server) return; + const cfg = this.cfg; + if (!cfg.enabled) return; + if (!cfg.token) { + this.deps.logger.warn('api server enabled but token is empty; not starting'); + return; + } + const server = createServer((req, res) => { + void this.handle(req, res); + }); + this.server = server; + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + this.server = undefined; + reject(err); + }; + server.once('error', onError); + server.listen(cfg.port, cfg.host, () => { + server.off('error', onError); + server.on('error', (err) => this.deps.logger.error({ err }, 'api server runtime error')); + this.deps.logger.info({ host: cfg.host, port: cfg.port }, 'local API server listening'); + resolve(); + }); + }).catch((err: unknown) => { + this.deps.logger.error({ err, port: cfg.port }, 'local API server failed to listen (non-fatal)'); + }); + } + + /** 优雅关闭:停止接收新连接、放行 in-flight 后落定。 */ + async stop(): Promise { + const server = this.server; + if (!server) return; + this.server = undefined; + await new Promise((resolve) => server.close(() => resolve())); + this.deps.logger.info('local API server stopped'); + } + + /** 配置(开关 / host / port)变更:停旧起新。 */ + async reconfigure(): Promise { + await this.stop(); + await this.start(); + } + + /** 常数时间比对 bearer token;缺 token 配置 / 非 Bearer 头 / 长度不符均判失败。 */ + private authorized(req: IncomingMessage): boolean { + const token = this.cfg.token; + if (!token) return false; + const header = req.headers['authorization']; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; + const provided = Buffer.from(header.slice('Bearer '.length)); + const expected = Buffer.from(token); + if (provided.length !== expected.length) return false; + return timingSafeEqual(provided, expected); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const started = Date.now(); + const method = req.method ?? 'GET'; + const rawUrl = req.url ?? '/'; + const qIdx = rawUrl.indexOf('?'); + const pathname = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl; + const search = qIdx >= 0 ? rawUrl.slice(qIdx + 1) : ''; + + let outcome: { status: number; code?: string }; + try { + if (!this.authorized(req)) throw new HttpError(401, ERROR_CODES.SV_UNAUTHORIZED); + // 兼容性门控:对所有 API 调用统一拦截过旧的 CLI(缺版本头 / 不可解析 → 放行)。 + if (isClientTooOld(req.headers[CLI_VERSION_HEADER])) { + throw new HttpError(426, ERROR_CODES.SV_CLIENT_TOO_OLD, { + minVersion: MIN_CLI_VERSION, + clientVersion: String(req.headers[CLI_VERSION_HEADER] ?? ''), + }); + } + const matched = matchRoute(method, pathname); + if (!matched) throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND); + const body = method === 'POST' ? await readJsonBody(req) : undefined; + const data = await matched.route.handler({ + params: matched.params, + query: new URLSearchParams(search), + body, + }); + sendOk(res, data); + outcome = { status: 200 }; + } catch (err) { + outcome = sendError(res, err); + } + this.deps.logger.debug( + { method, path: pathname, status: outcome.status, code: outcome.code, ms: Date.now() - started }, + 'api request', + ); + } +} diff --git a/apps/desktop/src/main/services/api-server/views.ts b/apps/desktop/src/main/services/api-server/views.ts new file mode 100644 index 00000000..4f9677e3 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/views.ts @@ -0,0 +1,104 @@ +import type { PragentRunInfo } from '@meebox/ipc'; +import type { + LocalPrStatus, + PlatformKind, + PrDiscoveryFilter, + ReviewRunTool, + ReviewerStatus, + StoredPullRequest, +} from '@meebox/shared'; + +/** + * PR 列表视图项:`GET /prs` 对外暴露的**精简投影**。这是「请求接口视图层的树结构约束方法」—— + * 单一投影函数 {@link toPrListItem} 定义列表返回的字段集合与次序,避免直接把整条 + * StoredPullRequest(含 description 明细、完整人员对象等)泄给列表消费方。 + * + * 收窄原则: + * - 只给标识与概览,**去掉 description 明细**(详情走 `GET /prs/{id}`); + * - **人员信息只留 slug**(reviewer 另带 status);头像 / 展示名等留给详情; + * - **字段顺序即输出顺序**:id / title / author / createdAt 优先,再给其余概览字段。 + */ +export interface PrListItem { + /** PR 的本地稳定标识(== StoredPullRequest.localId);写操作与详情端点均按此定位。 */ + id: string; + title: string; + /** 作者 slug(缺失时回退 name);不含展示名 / 头像。 */ + author: string; + createdAt: string; + /** 本人评审决断(pending / approved / needs_work)。 */ + status: LocalPrStatus; + state: 'open' | 'merged' | 'declined'; + draft: boolean; + platform: PlatformKind; + /** `projectKey/repoSlug`。 */ + repo: string; + /** 远端平台 PR 编号。 */ + remoteId: string; + updatedAt: string; + hasConflict: boolean; + /** 远端判定可直接合并(== mergeStatus.canMerge)。 */ + mergeable: boolean; + /** 命中的发现分类(一级 category)。 */ + categories: PrDiscoveryFilter[]; + /** 评审人:仅 slug + status。 */ + reviewers: Array<{ slug: string; status: ReviewerStatus }>; + unread: boolean; + unreadMentionCount: number; +} + +/** + * 某 PR 在运行队列里的一个 pr-agent run 视图项:`GET /prs/{id}/agent/runs` 的投影。用于让调用方 + * 发现可取消的 run(runId + tool + 运行 / 排队态),配合 `…/runs/{runId}/cancel` 做按 run 取消。 + */ +export interface PrAgentRunItem { + runId: string; + tool: ReviewRunTool; + /** active = 正在执行;waiting = 排队中。 */ + state: 'active' | 'waiting'; + /** 开始执行时间(ISO);waiting 为 null。 */ + startedAt: string | null; + enqueuedAt: string; + question?: string; +} + +/** 从队列快照筛出属于该 PR 的 run(active 在前、waiting 在后),投影为精简项。 */ +export function toPrAgentRuns( + queue: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }, + prId: string, +): PrAgentRunItem[] { + const pick = (r: PragentRunInfo, state: 'active' | 'waiting'): PrAgentRunItem => ({ + runId: r.runId, + tool: r.tool, + state, + startedAt: r.startedAt, + enqueuedAt: r.enqueuedAt, + ...(r.question ? { question: r.question } : {}), + }); + return [ + ...queue.active.filter((r) => r.prLocalId === prId).map((r) => pick(r, 'active')), + ...queue.waiting.filter((r) => r.prLocalId === prId).map((r) => pick(r, 'waiting')), + ]; +} + +/** 把存储态 PR 投影为列表视图项。对象字面量的键序即 JSON 输出顺序(CLI 视图层据此渲染)。 */ +export function toPrListItem(pr: StoredPullRequest): PrListItem { + return { + id: pr.localId, + title: pr.title, + author: pr.author.slug ?? pr.author.name, + createdAt: pr.createdAt, + status: pr.localStatus, + state: pr.state, + draft: pr.draft, + platform: pr.platform, + repo: `${pr.repo.projectKey}/${pr.repo.repoSlug}`, + remoteId: pr.remoteId, + updatedAt: pr.updatedAt, + hasConflict: pr.hasConflict, + mergeable: pr.mergeStatus?.canMerge === true, + categories: pr.discoveryFilters, + reviewers: pr.reviewers.map((r) => ({ slug: r.slug ?? r.name, status: r.status })), + unread: pr.unread ?? false, + unreadMentionCount: pr.unreadMentionCount ?? 0, + }; +} diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts index 8bc627a5..90b3c9e9 100644 --- a/apps/desktop/src/main/services/context.ts +++ b/apps/desktop/src/main/services/context.ts @@ -31,6 +31,8 @@ export interface RegisterDeps { /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ reconfigureConnections: () => Promise; repoMirror: RepoMirrorManager; + /** 重建本地 API 监听器使 service 配置(开关 / host / port)变更热生效(config:setService 写盘后调用)。 */ + reconfigureApiServer: () => Promise; } /** diff --git a/apps/desktop/src/main/services/notifications.ts b/apps/desktop/src/main/services/notifications.ts index c40c9902..09e999da 100644 --- a/apps/desktop/src/main/services/notifications.ts +++ b/apps/desktop/src/main/services/notifications.ts @@ -19,11 +19,14 @@ import { ensureAvatarFile, type AvatarFileDeps } from './avatar.js'; /** 一轮最多单独弹的通知条数(各带定位);超出部分折叠为一条「查看更多」提示,避免涌入时的通知风暴。 */ const INDIVIDUAL_LIMIT = 5; -/** 通知事件类型 → i18n 文案分组名(new_pr 的 key 为 newPr,其余同名)。 */ +/** 通知事件类型 → i18n 文案分组名(new_pr 的 key 为 newPr,authored_* 转驼峰,其余同名)。 */ const I18N_GROUP: Record = { new_pr: 'newPr', mention: 'mention', reply: 'reply', + authored_comment: 'authoredComment', + authored_needs_work: 'authoredNeedsWork', + authored_conflict: 'authoredConflict', }; /** 类型 emoji(Windows toast 单图标槽给了头像,故类型用 emoji 在标题前标记)。 */ @@ -31,6 +34,9 @@ const TYPE_EMOJI: Record = { new_pr: '🔀', mention: '💬', reply: '↩️', + authored_comment: '💬', + authored_needs_work: '📝', + authored_conflict: '⚠️', }; /** 点击通知:唤起并聚焦主窗口(最小化则先还原)。 */ @@ -138,6 +144,9 @@ export async function showPollNotifications( new_pr: cfg.new_pr, reply: cfg.reply, mention: cfg.mention, + authored_comment: cfg.authored_comment, + authored_needs_work: cfg.authored_needs_work, + authored_conflict: cfg.authored_conflict, }; const filtered = events.filter((e) => allow[e.kind]); if (filtered.length === 0) return; diff --git a/apps/desktop/src/main/services/pr-agent/run-executor.ts b/apps/desktop/src/main/services/pr-agent/run-executor.ts index 774ec5ca..15ed6636 100644 --- a/apps/desktop/src/main/services/pr-agent/run-executor.ts +++ b/apps/desktop/src/main/services/pr-agent/run-executor.ts @@ -87,7 +87,7 @@ export class RunExecutor { return updated ?? { ...run, ...patch }; }; - const wt = await this.prepareWorkspace(pr); + const wt = await this.prepareWorkspace(pr, req.scope); try { const { env, extraArgs, askLangSuffix } = await this.buildInvocation( req, @@ -254,6 +254,10 @@ export class RunExecutor { model: activeLlmForRecord?.model || undefined, // 复评引用前向链:随 run 落盘,UI 据此在 /ask 卡上展示「复评自…」徽标 + 裁决动作。 referencedFinding: req.tool === 'ask' ? req.referencedFinding : undefined, + // 触发来源随 run 落盘:user 来源的 run 由 ChatPane 补命令回显气泡;agent 子 run 不回显。 + origin: item.priority, + // 单 commit 评审范围随 run 落盘:结果卡据此展示范围徽标。 + scope: req.scope, }); // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播(经调度层)。 item.info = { ...item.info, startedAt: run.startedAt }; @@ -265,16 +269,25 @@ export class RunExecutor { return run; } - /** 阶段②:同步镜像 + 按固定 merge-base 物化 worktree(与 UI diff 同源,评审基于 PR 自分叉的改动)。 */ - private async prepareWorkspace(pr: QueueItem['pr']) { + /** + * 阶段②:同步镜像 + 物化 worktree(与 UI diff 同源,评审基于 PR 自分叉的改动)。 + * 缺省按固定 merge-base 定界 PR 全量(head=PR 源 sha,base=merge-base);传入单 commit 范围(scope)时 + * 改按该 commit 自身改动定界(head=scope.sha,base=scope.parent),pr-agent 只见 parent..sha 的 diff。 + */ + private async prepareWorkspace(pr: QueueItem['pr'], scope?: QueueItem['req']['scope']) { const { repoMirror, pr: prService } = this.ctx; const repoId = prService.repoIdentityFor(pr); // 走 ensureMirrorReadyForPr(而非裸 syncMirror):与 UI diff 同源,且复用其自愈——源分支被删 / 强推后 - // 按平台精确 fetch PR 头引用补齐 head sha,否则 materializeWorktree 建 meebox/head 会因对象缺失失败。 + // 按平台精确 fetch PR 头引用补齐 head sha,否则 materializeWorktree 建 head 分支会因对象缺失失败。 await prService.ensureMirrorReadyForPr(pr); + if (scope) { + // 单 commit 范围:head=目标 commit,base=其父 commit → LOCAL__TARGET_BRANCH 指向 parent, + // pr-agent 只见该 commit 自身改动。parent 是 head 的祖先、随镜像同步而在,无需另取。 + return repoMirror.materializeWorktree(repoId, scope.sha, scope.parent, pr.localId); + } // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base,而非 targetRef.sha 漂移后混入别的 PR 的两点对比。 const diffBase = await prService.resolveDiffBaseSha(pr); - return repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); + return repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase, pr.localId); } /** @@ -361,6 +374,10 @@ export class RunExecutor { // /ask 代码建议数量软约束(与 /review /improve 共用同一设置)。 maxCodeSuggestions: req.tool === 'ask' ? bootstrap.config.agent.strategy.max_code_suggestions : undefined, + // /ask 代码检索指引:仅 CLI 提供方(子进程 cwd 落在完整 worktree、可用文件工具)注入,引导定向检索 + // (内置只读搜索 / grep 查符号 · 只读所需行段)替代整文件通读,降低 agentic 探索成本。刻意只用只读工具集 + // (headless default 模式下非只读工具会中止会话,故不诱导 rg)。API 提供方无文件访问、不注入。 + worktreeRetrieval: req.tool === 'ask' && activeLlm?.provider === 'cli', }); // /ask 的 pr_questions prompt **不渲染 extra_instructions**(与 describe/review/improve 不同), // 经 env 注入对 /ask 是死字段。故 /ask 的指令改为拼进「问题」(user turn,见下方 askQuestion), diff --git a/apps/desktop/src/main/services/pr-agent/run-queue.ts b/apps/desktop/src/main/services/pr-agent/run-queue.ts index a8f02562..44a3164c 100644 --- a/apps/desktop/src/main/services/pr-agent/run-queue.ts +++ b/apps/desktop/src/main/services/pr-agent/run-queue.ts @@ -25,6 +25,7 @@ export interface QueueItem { question?: string; referencedContext?: string; referencedFinding?: ReviewRun['referencedFinding']; + scope?: ReviewRun['scope']; }; pr: StoredPullRequest; resolve: (run: ReviewRun) => void; @@ -76,9 +77,12 @@ export class RunQueue { priority: RunPriority = 'user', referencedContext?: string, referencedFinding?: ReviewRun['referencedFinding'], + scope?: ReviewRun['scope'], ): Promise { const { logger } = this.ctx; - if (tool !== 'ask') { + // dedup 仅约束「PR 全量」的同工具重复;/ask 每次问题不同、单 commit 范围(scope)是定向动作,均放行 + // (允许全量 review 之外再对某 commit 单独 review,互不视作重复)。 + if (tool !== 'ask' && !scope) { const sameTask = (q: QueueItem): boolean => q.info.prLocalId === pr.localId && q.info.tool === tool; if ([...this.active.values()].some(sameTask) || this.waiting.some(sameTask)) { @@ -96,6 +100,8 @@ export class RunQueue { prNumber: pr.remoteId, tool, question: tool === 'ask' ? question : undefined, + origin: priority, + scope, enqueuedAt: new Date().toISOString(), startedAt: null, }, @@ -107,6 +113,8 @@ export class RunQueue { question, referencedContext: tool === 'ask' ? referencedContext : undefined, referencedFinding: tool === 'ask' ? referencedFinding : undefined, + // 单 commit 范围对所有工具生效(不限 ask):executor 据此物化 parent..sha 的 worktree。 + scope, }, pr, priority, diff --git a/apps/desktop/src/main/services/pr-service.ts b/apps/desktop/src/main/services/pr-service.ts index 8a2f2913..b1b35c53 100644 --- a/apps/desktop/src/main/services/pr-service.ts +++ b/apps/desktop/src/main/services/pr-service.ts @@ -8,7 +8,12 @@ import { writeDiffBaseCache, } from '@meebox/poller'; import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; -import { pullRequestHeadRefspec, type StoredPullRequest } from '@meebox/shared'; +import { + AppError, + ERROR_CODES, + pullRequestHeadRefspec, + type StoredPullRequest, +} from '@meebox/shared'; import type { PlatformAdapter } from '@meebox/platform-core'; import type { JsonFileStateStore } from '@meebox/state-store'; import type { ConnectionRuntime } from '../adapters.js'; @@ -53,7 +58,7 @@ export class PrService { if (pr) return pr; const archived = await readPrMeta(this.deps.archiveStore, localId); if (archived) return archived.pr; - throw new Error(`PR not found in local state: ${localId}`); + throw new AppError(ERROR_CODES.PR_NOT_FOUND, { localId }, `PR not found in local state: ${localId}`); } /** diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 3ca14390..cc230b0b 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { ReviewRunCommitScope } from '@meebox/shared'; import { invoke } from './api'; import { ChatPane } from './components/features/chat'; import { MainPane } from './components/layout/MainPane'; @@ -81,6 +82,12 @@ export default function App() { }, []); // PR 状态筛选(待处理 / 全部 / 冲突 / 可合并等):提升到 App 以便命令面板亦可驱动、折叠侧栏不丢选择。 const [statusFilter, setStatusFilter] = useState('pending'); + // 当前 Diff 视图选中的单 commit 范围(DiffView 上报):作为聊天区命令的隐式范围(见 ChatPane)。 + // 按 sha 去重,避免 DiffView 每次 render 传新对象引发的重渲染回环。 + const [viewCommitScope, setViewCommitScope] = useState(null); + const handleViewCommitScope = useCallback((s: ReviewRunCommitScope | null) => { + setViewCommitScope((prev) => (prev?.sha === s?.sha ? prev : s)); + }, []); // PR 导航 / 范围领域(发现分类 / 活跃·归档切换 / 归档懒加载 / 按 URL 打开 / 定位跳转 / 通知点击导航 + // 跨组件 Diff·Tab 跳转意图)——领域逻辑归 usePrNavigation;选中态 / 已读仍由 usePullRequests 拥有。 const { @@ -203,6 +210,7 @@ export default function App() { {!sidebarCollapsed && ( { setSelectedId(pr.localId); @@ -240,6 +248,7 @@ export default function App() { onRequestDiffNav={(target) => setPendingDiffNav(target)} pendingTab={pendingTab} onPendingTabConsumed={() => setPendingTab(null)} + onViewCommitScopeChange={handleViewCommitScope} /> ) : ( 0} /> @@ -262,6 +271,7 @@ export default function App() { currentLlmModel={ boot.config.llm.profiles.find((p) => p.id === boot.config.llm.active_id)?.model ?? null } + viewCommitScope={viewCommitScope} />

{title}

- {headerClose === 'icon' && ( - - )} - {headerClose === 'text' && ( - + {(headerActions !== undefined || headerClose) && ( +
+ {headerActions} + {headerClose === 'icon' && ( + + )} + {headerClose === 'text' && ( + + )} +
)} )} diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx index f4ce5c7e..7fa94b40 100644 --- a/apps/desktop/src/renderer/src/components/common/icons.tsx +++ b/apps/desktop/src/renderer/src/components/common/icons.tsx @@ -256,6 +256,15 @@ export function ShareIcon({ size = 14 }: IconProps) { ); } +/** 拼图块(extension / plugin):集成 / 扩展 的通用隐喻。设置「集成」分区导航用。 */ +export function PuzzleIcon({ size = 14 }: IconProps) { + return ( + + ); +} + /** 评论:带文字行的对话气泡。finding 卡「编辑成评论草稿」动作用(与 ChatIcon 区分:内含文字行)。 */ export function CommentIcon({ size = 14 }: IconProps) { return ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx index 55a88dd7..31b6c4c3 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx @@ -5,6 +5,7 @@ import type { LocalPrStatus, PrAgentStatus, ReviewRun, + ReviewRunCommitScope, StoredPullRequest, } from '@meebox/shared'; import { ChatIcon, TrashIcon, ConfirmModal, PaneLoading } from '../../common'; @@ -70,6 +71,12 @@ interface ChatPaneProps { llmConfigured?: boolean; /** 打开设置面板(LLM 未配置提示里的「去设置」按钮用) */ onOpenSettings?: () => void; + /** + * 当前 Diff 视图选中的单 commit 范围(无 / root commit 为 null):作为本 PR 聊天区命令的**隐式范围**—— + * 直接键入的 /describe /review /improve /ask 自动限定在该 commit(输入栏显示可撤销范围 chip)。撤销该 chip + * 后本会话不再随视图范围(直到切换到别的 commit)。auto review 微流程不受此影响、恒作用于 PR 全量。 + */ + viewCommitScope?: ReviewRunCommitScope | null; } /** @@ -94,6 +101,7 @@ export function ChatPane({ currentLlmModel, llmConfigured = true, onOpenSettings, + viewCommitScope, }: ChatPaneProps) { const { t } = useTranslation(); const startResize = (e: React.MouseEvent): void => { @@ -139,10 +147,18 @@ export function ChatPane({ // 复评引用态:点 finding「引用」→ 仅挂到输入栏(chip);不自动填写问题,用户自行输入。发送时携带该引用。 const [refFinding, setRefFinding] = useState<{ finding: Finding; run: ReviewRun } | null>(null); - // PR 切换清掉引用态,避免跨 PR 残留。 + // 「脱离视图范围」态:用户 ✕ 掉范围 chip 后,本会话命令不再随 Diff 视图选中的 commit(直到切到别的 commit + // 或切 PR 才复位)。默认跟随视图范围。 + const [scopeDetached, setScopeDetached] = useState(false); + // PR 切换清掉引用态、复位脱离态,避免跨 PR 残留。 useEffect(() => { setRefFinding(null); + setScopeDetached(false); }, [prLocalId]); + // 切到别的 commit(或清空视图范围)时复位脱离态:新选中的 commit 重新作为隐式范围生效。 + useEffect(() => { + setScopeDetached(false); + }, [viewCommitScope?.sha]); const onReferenceFinding = (finding: Finding, run: ReviewRun): void => { setRefFinding({ finding, run }); }; @@ -153,6 +169,11 @@ export function ChatPane({ const referencedContext = diffSelection && !selectionIgnored ? formatReferencedContext(diffSelection) : undefined; + // 本 PR 聊天区命令的生效范围:跟随 Diff 视图选中的 commit,除非用户已脱离(scopeDetached)。 + // 同一时刻只允许一个 scope 生效——存在 Diff 选区时以选区为准(更细粒度),commit 范围暂挂起、其 chip + // 亦隐藏(见 commitScopeChip),取消选区后自动还原。 + const effectiveScope = diffSelection || scopeDetached ? null : (viewCommitScope ?? null); + // 会话态 + 生命周期(切 PR 重载 / 流式步骤 / 分页 / 自动滚动) const session = useChatSession(prLocalId, myActiveIds); @@ -199,6 +220,12 @@ export function ChatPane({ setRefFinding(null); }; + // 发送一条限定在当前视图 commit 的 /ask:把生效范围随问题带下去(限定 parent..sha 的 diff)。 + const sendScopedAsk = (q: string): void => { + if (!effectiveScope) return; + void actions.handleRun('ask', q, undefined, undefined, effectiveScope); + }; + // 历史时间线归并 + 「思考中」实时计时锚点 const { timeline, thinkingSince } = useChatTimeline({ visibleRuns, @@ -352,6 +379,7 @@ export function ChatPane({ tool={entry.active.tool} runId={entry.active.runId} question={entry.active.question} + scope={entry.active.scope} lines={linesByRunId.get(entry.active.runId) ?? []} startedAt={new Date(entry.active.startedAt ?? entry.active.enqueuedAt).getTime()} model={currentLlmModel ?? null} @@ -403,13 +431,29 @@ export function ChatPane({ sendReferencedAsk(q ?? ''); return; } - void actions.handleRun(tool, q, tool === 'ask' ? referencedContext : undefined); + if (tool === 'ask' && effectiveScope) { + sendScopedAsk(q ?? ''); + return; + } + // describe/review/improve 亦跟随视图 commit 范围(effectiveScope);无范围时为 PR 全量。 + void actions.handleRun( + tool, + q, + tool === 'ask' ? referencedContext : undefined, + undefined, + tool === 'ask' ? undefined : (effectiveScope ?? undefined), + ); }} onAgentAsk={(q) => { if (refFinding) { sendReferencedAsk(q); return; } + // 视图选中某 commit 时,自然语言提问也走该 commit 范围的 /ask(限定该 commit 的 diff)。 + if (effectiveScope) { + sendScopedAsk(q); + return; + } void actions.handleAgentAsk(q, referencedContext); }} onCancel={hasMyActive || agentRunningHere ? actions.handleStopAll : undefined} @@ -436,6 +480,21 @@ export function ChatPane({ } : null } + // 单 commit 范围 chip:视图选中某 commit 时显示(选中态源自视图);点击切换启用/禁用—— + // 禁用(scopeDetached)时命令回到 PR 全量、chip 置灰,切到别的 commit 或切 PR 复位为启用。 + // 同一时刻只允许一个 scope:存在 Diff 选区时让位于选区 chip(隐藏本 chip),取消选区后自动还原。 + commitScopeChip={ + viewCommitScope && !diffSelection + ? { + // 仅展示短 hash,不带主题(避免 chip 内容过长);主题在 Diff 视图与结果卡徽标已可见。 + label: viewCommitScope.abbreviatedSha, + disabled: scopeDetached, + onToggle: () => { + setScopeDetached((d) => !d); + }, + } + : null + } /> {showRulePreview && matchedRules.length > 0 && ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index 0f560450..a2e15fd5 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -5,7 +5,14 @@ import type { ReviewRunTool, StoredPullRequest, } from '@meebox/shared'; -import { AutoReviewIcon, EyeOffIcon, FileTreeIcon, SendIcon, StopIcon } from '../../../common'; +import { + AutoReviewIcon, + CommitIcon, + EyeOffIcon, + FileTreeIcon, + SendIcon, + StopIcon, +} from '../../../common'; import { useChatInput } from '../hooks/useChatInput'; import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag'; @@ -48,6 +55,11 @@ interface ChatInputBarProps { onToggleSelection: () => void; /** 复评引用 chip:引用了某条 finding 时展示「复评 」+ 清除;null = 不渲染。 */ referenceChip?: { label: string; onClear: () => void } | null; + /** + * 单 commit 范围 chip:跟随 Diff 视图选中的 commit 展示「短 SHA · 主题」。存在选中即显示,点击**切换启用/禁用** + * (禁用不删除 chip,本会话命令回到 PR 全量;禁用态置灰 + eye-slash)——选中态源自视图,可手动禁用。 + */ + commitScopeChip?: { label: string; disabled: boolean; onToggle: () => void } | null; } /** @@ -74,6 +86,7 @@ export function ChatInputBar({ selectionIgnored, onToggleSelection, referenceChip, + commitScopeChip, }: ChatInputBarProps) { const { t } = useTranslation(); const { @@ -268,6 +281,26 @@ export function ChatInputBar({ )} + {/* 单 commit 范围 chip:跟随视图选中的 commit 展示「短 SHA · 主题」。点击切换启用/禁用(不删除)—— + 启用时命令限定在该 commit(parent..sha),禁用则回到 PR 全量、置灰 + eye-slash。 */} + {commitScopeChip && ( + <> +